@azumag/opencode-rate-limit-fallback 1.37.0 → 1.40.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.
@@ -37,6 +37,11 @@ export declare class CircuitBreaker {
37
37
  * @returns The current circuit state
38
38
  */
39
39
  getState(modelKey: string): CircuitBreakerState;
40
+ /**
41
+ * Show toast notification for circuit state transition
42
+ * @private
43
+ */
44
+ private showStateTransitionToast;
40
45
  /**
41
46
  * Clean up stale entries from the circuits map
42
47
  */
@@ -44,17 +44,8 @@ export class CircuitBreaker {
44
44
  oldState: oldStateType,
45
45
  newState: newStateType,
46
46
  });
47
- // Show toast notification for HALF_OPEN transition (recovery attempt)
48
- if (newStateType === 'HALF_OPEN' && this.client) {
49
- safeShowToast(this.client, {
50
- body: {
51
- title: "Circuit Recovery Attempt",
52
- message: `Attempting recovery for ${modelKey} after ${this.config.recoveryTimeoutMs}ms`,
53
- variant: "info",
54
- duration: 3000,
55
- },
56
- });
57
- }
47
+ // Show toast notification for state transition
48
+ this.showStateTransitionToast(modelKey, oldStateType, newStateType);
58
49
  // Record metrics
59
50
  if (this.metricsManager) {
60
51
  this.metricsManager.recordCircuitBreakerStateTransition(modelKey, oldStateType, newStateType);
@@ -74,23 +65,14 @@ export class CircuitBreaker {
74
65
  const oldState = circuit.getState().state;
75
66
  circuit.onSuccess();
76
67
  const newState = circuit.getState().state;
77
- // Log state transition
68
+ // Log state transition and show toast
78
69
  if (oldState !== newState) {
79
70
  this.logger.info(`Circuit breaker state changed for ${modelKey}`, {
80
71
  oldState,
81
72
  newState,
82
73
  });
83
- // Show toast notification for circuit close
84
- if (newState === 'CLOSED' && oldState !== 'CLOSED' && this.client) {
85
- safeShowToast(this.client, {
86
- body: {
87
- title: "Circuit Closed",
88
- message: `Circuit breaker closed for ${modelKey} - service recovered`,
89
- variant: "success",
90
- duration: 3000,
91
- },
92
- });
93
- }
74
+ // Show toast notification for state transition
75
+ this.showStateTransitionToast(modelKey, oldState, newState);
94
76
  // Record metrics
95
77
  if (this.metricsManager) {
96
78
  this.metricsManager.recordCircuitBreakerStateTransition(modelKey, oldState, newState);
@@ -115,35 +97,15 @@ export class CircuitBreaker {
115
97
  const oldState = circuit.getState().state;
116
98
  circuit.onFailure();
117
99
  const newState = circuit.getState().state;
118
- // Log state transition
100
+ // Log state transition and show toast
119
101
  if (oldState !== newState) {
120
102
  this.logger.warn(`Circuit breaker state changed for ${modelKey}`, {
121
103
  oldState,
122
104
  newState,
123
105
  failureCount: circuit.getState().failureCount,
124
106
  });
125
- // Show toast notification for circuit open
126
- if (newState === 'OPEN' && this.client) {
127
- safeShowToast(this.client, {
128
- body: {
129
- title: "Circuit Opened",
130
- message: `Circuit breaker opened for ${modelKey} after failure threshold`,
131
- variant: "warning",
132
- duration: 5000,
133
- },
134
- });
135
- }
136
- // Show toast notification for circuit close
137
- if (newState === 'CLOSED' && oldState !== 'CLOSED' && this.client) {
138
- safeShowToast(this.client, {
139
- body: {
140
- title: "Circuit Closed",
141
- message: `Circuit breaker closed for ${modelKey} - service recovered`,
142
- variant: "success",
143
- duration: 3000,
144
- },
145
- });
146
- }
107
+ // Show toast notification for state transition
108
+ this.showStateTransitionToast(modelKey, oldState, newState);
147
109
  // Record metrics
148
110
  if (this.metricsManager) {
149
111
  this.metricsManager.recordCircuitBreakerStateTransition(modelKey, oldState, newState);
@@ -169,6 +131,50 @@ export class CircuitBreaker {
169
131
  }
170
132
  return circuit.getState();
171
133
  }
134
+ /**
135
+ * Show toast notification for circuit state transition
136
+ * @private
137
+ */
138
+ showStateTransitionToast(modelKey, oldState, newState) {
139
+ if (!this.client) {
140
+ return;
141
+ }
142
+ switch (newState) {
143
+ case 'OPEN':
144
+ safeShowToast(this.client, {
145
+ body: {
146
+ title: "Circuit Opened",
147
+ message: `Circuit breaker opened for ${modelKey} after failure threshold`,
148
+ variant: "warning",
149
+ duration: 5000,
150
+ },
151
+ });
152
+ break;
153
+ case 'HALF_OPEN':
154
+ safeShowToast(this.client, {
155
+ body: {
156
+ title: "Circuit Recovery Attempt",
157
+ message: `Attempting recovery for ${modelKey} after ${this.config.recoveryTimeoutMs}ms`,
158
+ variant: "info",
159
+ duration: 3000,
160
+ },
161
+ });
162
+ break;
163
+ case 'CLOSED':
164
+ // Only show toast for circuit close when transitioning from non-CLOSED state
165
+ if (oldState !== 'CLOSED') {
166
+ safeShowToast(this.client, {
167
+ body: {
168
+ title: "Circuit Closed",
169
+ message: `Circuit breaker closed for ${modelKey} - service recovered`,
170
+ variant: "success",
171
+ duration: 3000,
172
+ },
173
+ });
174
+ }
175
+ break;
176
+ }
177
+ }
172
178
  /**
173
179
  * Clean up stale entries from the circuits map
174
180
  */
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Default configuration constants
3
+ */
4
+ /**
5
+ * Default health persistence path
6
+ */
7
+ export declare const DEFAULT_HEALTH_PERSISTENCE_PATH: string;
8
+ /**
9
+ * Default health tracker configuration
10
+ */
11
+ export declare const DEFAULT_HEALTH_TRACKER_CONFIG: {
12
+ readonly enabled: true;
13
+ readonly path: string;
14
+ readonly responseTimeThreshold: 2000;
15
+ readonly responseTimePenaltyDivisor: 200;
16
+ readonly failurePenaltyMultiplier: 15;
17
+ readonly minRequestsForReliableScore: 3;
18
+ };
19
+ /**
20
+ * Default retry policy configuration
21
+ */
22
+ export declare const DEFAULT_RETRY_POLICY_CONFIG: {
23
+ readonly maxRetries: 3;
24
+ readonly strategy: "immediate";
25
+ readonly baseDelayMs: 1000;
26
+ readonly maxDelayMs: 30000;
27
+ readonly jitterEnabled: false;
28
+ readonly jitterFactor: 0.1;
29
+ };
30
+ /**
31
+ * Default polynomial retry parameters
32
+ */
33
+ export declare const DEFAULT_POLYNOMIAL_BASE = 1.5;
34
+ export declare const DEFAULT_POLYNOMIAL_EXPONENT = 2;
35
+ /**
36
+ * Default circuit breaker configuration
37
+ */
38
+ export declare const DEFAULT_CIRCUIT_BREAKER_CONFIG: {
39
+ readonly enabled: false;
40
+ readonly failureThreshold: 5;
41
+ readonly recoveryTimeoutMs: 60000;
42
+ readonly halfOpenMaxCalls: 1;
43
+ readonly successThreshold: 2;
44
+ };
45
+ /**
46
+ * Default cooldown period (ms)
47
+ */
48
+ export declare const DEFAULT_COOLDOWN_MS: number;
49
+ /**
50
+ * Default fallback mode
51
+ */
52
+ export declare const DEFAULT_FALLBACK_MODE: "cycle";
53
+ /**
54
+ * Default log configuration
55
+ */
56
+ export declare const DEFAULT_LOG_CONFIG: {
57
+ readonly level: "warn";
58
+ readonly format: "simple";
59
+ readonly enableTimestamp: true;
60
+ };
61
+ /**
62
+ * Default metrics configuration
63
+ */
64
+ export declare const DEFAULT_METRICS_CONFIG: {
65
+ readonly enabled: false;
66
+ readonly output: {
67
+ readonly console: true;
68
+ readonly format: "pretty";
69
+ };
70
+ readonly resetInterval: "daily";
71
+ };
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Default configuration constants
3
+ */
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+ // ============================================================================
7
+ // Health Tracker Defaults
8
+ // ============================================================================
9
+ /**
10
+ * Default health persistence path
11
+ */
12
+ export const DEFAULT_HEALTH_PERSISTENCE_PATH = join(homedir(), '.opencode', 'rate-limit-fallback-health.json');
13
+ /**
14
+ * Default health tracker configuration
15
+ */
16
+ export const DEFAULT_HEALTH_TRACKER_CONFIG = {
17
+ enabled: true,
18
+ path: DEFAULT_HEALTH_PERSISTENCE_PATH,
19
+ responseTimeThreshold: 2000, // ms - threshold for response time penalty
20
+ responseTimePenaltyDivisor: 200, // divisor for response time penalty calculation
21
+ failurePenaltyMultiplier: 15, // penalty per consecutive failure
22
+ minRequestsForReliableScore: 3, // min requests before score is reliable
23
+ };
24
+ // ============================================================================
25
+ // Retry Defaults
26
+ // ============================================================================
27
+ /**
28
+ * Default retry policy configuration
29
+ */
30
+ export const DEFAULT_RETRY_POLICY_CONFIG = {
31
+ maxRetries: 3,
32
+ strategy: "immediate",
33
+ baseDelayMs: 1000,
34
+ maxDelayMs: 30000,
35
+ jitterEnabled: false,
36
+ jitterFactor: 0.1,
37
+ };
38
+ /**
39
+ * Default polynomial retry parameters
40
+ */
41
+ export const DEFAULT_POLYNOMIAL_BASE = 1.5;
42
+ export const DEFAULT_POLYNOMIAL_EXPONENT = 2;
43
+ // ============================================================================
44
+ // Circuit Breaker Defaults
45
+ // ============================================================================
46
+ /**
47
+ * Default circuit breaker configuration
48
+ */
49
+ export const DEFAULT_CIRCUIT_BREAKER_CONFIG = {
50
+ enabled: false,
51
+ failureThreshold: 5,
52
+ recoveryTimeoutMs: 60000,
53
+ halfOpenMaxCalls: 1,
54
+ successThreshold: 2,
55
+ };
56
+ // ============================================================================
57
+ // Plugin Defaults
58
+ // ============================================================================
59
+ /**
60
+ * Default cooldown period (ms)
61
+ */
62
+ export const DEFAULT_COOLDOWN_MS = 60 * 1000;
63
+ /**
64
+ * Default fallback mode
65
+ */
66
+ export const DEFAULT_FALLBACK_MODE = "cycle";
67
+ // ============================================================================
68
+ // Logging Defaults
69
+ // ============================================================================
70
+ /**
71
+ * Default log configuration
72
+ */
73
+ export const DEFAULT_LOG_CONFIG = {
74
+ level: "warn",
75
+ format: "simple",
76
+ enableTimestamp: true,
77
+ };
78
+ // ============================================================================
79
+ // Metrics Defaults
80
+ // ============================================================================
81
+ /**
82
+ * Default metrics configuration
83
+ */
84
+ export const DEFAULT_METRICS_CONFIG = {
85
+ enabled: false,
86
+ output: {
87
+ console: true,
88
+ format: "pretty",
89
+ },
90
+ resetInterval: "daily",
91
+ };
@@ -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;
@@ -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
  }
@@ -147,10 +149,16 @@ export class FallbackHandler {
147
149
  * Handle the rate limit fallback process
148
150
  */
149
151
  async handleRateLimitFallback(sessionID, currentProviderID, currentModelID) {
152
+ // Resolve target session before acquiring lock
153
+ const rootSessionID = this.subagentTracker.getRootSession(sessionID);
154
+ const targetSessionID = rootSessionID || sessionID;
155
+ // Session-level lock: prevent concurrent fallback processing
156
+ if (this.sessionLock.has(targetSessionID)) {
157
+ this.logger.debug(`Fallback already in progress for session ${targetSessionID}, skipping`);
158
+ return;
159
+ }
160
+ this.sessionLock.add(targetSessionID);
150
161
  try {
151
- // Get root session and hierarchy using subagent tracker
152
- const rootSessionID = this.subagentTracker.getRootSession(sessionID);
153
- const targetSessionID = rootSessionID || sessionID;
154
162
  const hierarchy = this.subagentTracker.getHierarchy(sessionID);
155
163
  // If no model info provided, try to get from tracked session model
156
164
  if (!currentProviderID || !currentModelID) {
@@ -309,10 +317,11 @@ export class FallbackHandler {
309
317
  name: errorName,
310
318
  });
311
319
  // Record retry failure on error
312
- const rootSessionID = this.subagentTracker.getRootSession(sessionID);
313
- const targetSessionID = rootSessionID || sessionID;
314
320
  this.retryManager.recordFailure(targetSessionID);
315
321
  }
322
+ finally {
323
+ this.sessionLock.delete(targetSessionID);
324
+ }
316
325
  }
317
326
  /**
318
327
  * Handle message updated events for metrics recording
@@ -419,6 +428,7 @@ export class FallbackHandler {
419
428
  this.retryState.clear();
420
429
  this.fallbackInProgress.clear();
421
430
  this.fallbackMessages.clear();
431
+ this.sessionLock.clear();
422
432
  this.retryManager.destroy();
423
433
  // Destroy circuit breaker
424
434
  if (this.circuitBreaker) {
@@ -18,6 +18,7 @@ export declare class HealthTracker {
18
18
  private responseTimeThreshold;
19
19
  private responseTimePenaltyDivisor;
20
20
  private failurePenaltyMultiplier;
21
+ private minRequestsForReliableScore;
21
22
  private persistenceDebounceMs;
22
23
  constructor(config: PluginConfig, logger: Logger);
23
24
  /**
@@ -4,23 +4,12 @@
4
4
  */
5
5
  import { getModelKey } from '../utils/helpers.js';
6
6
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
7
- import { join, dirname } from 'path';
8
- import { homedir } from 'os';
7
+ import { dirname } from 'path';
8
+ import { DEFAULT_HEALTH_TRACKER_CONFIG } from '../config/defaults.js';
9
9
  /**
10
- * Default health persistence path
10
+ * Default health tracker configuration
11
11
  */
12
- const DEFAULT_HEALTH_PERSISTENCE_PATH = join(homedir(), '.opencode', 'rate-limit-fallback-health.json');
13
- /**
14
- * Minimum requests before health score is considered reliable
15
- */
16
- const MIN_REQUESTS_FOR_RELIABLE_SCORE = 3;
17
- /**
18
- * Default health configuration
19
- */
20
- const DEFAULT_HEALTH_CONFIG = {
21
- enabled: true,
22
- path: DEFAULT_HEALTH_PERSISTENCE_PATH,
23
- };
12
+ const DEFAULT_HEALTH_CONFIG = DEFAULT_HEALTH_TRACKER_CONFIG;
24
13
  /**
25
14
  * Model Health Tracker class
26
15
  */
@@ -36,22 +25,26 @@ export class HealthTracker {
36
25
  responseTimeThreshold;
37
26
  responseTimePenaltyDivisor;
38
27
  failurePenaltyMultiplier;
28
+ minRequestsForReliableScore;
39
29
  persistenceDebounceMs;
40
30
  constructor(config, logger) {
41
31
  this.healthData = new Map();
42
32
  // Parse health persistence config
43
- const healthPersistence = config.healthPersistence || DEFAULT_HEALTH_CONFIG;
44
- this.persistenceEnabled = healthPersistence.enabled !== false;
45
- this.persistencePath = healthPersistence.path || DEFAULT_HEALTH_PERSISTENCE_PATH;
33
+ const healthConfig = config.healthPersistence
34
+ ? { ...DEFAULT_HEALTH_CONFIG, ...config.healthPersistence }
35
+ : DEFAULT_HEALTH_CONFIG;
36
+ this.persistenceEnabled = healthConfig.enabled !== false;
37
+ this.persistencePath = healthConfig.path || DEFAULT_HEALTH_CONFIG.path;
46
38
  this.healthBasedSelectionEnabled = config.enableHealthBasedSelection || false;
47
39
  // Initialize logger
48
40
  this.logger = logger;
49
41
  // Initialize save state
50
42
  this.savePending = false;
51
- // Initialize configurable thresholds (can be customized via config if needed)
52
- this.responseTimeThreshold = 2000; // ms - threshold for response time penalty
53
- this.responseTimePenaltyDivisor = 200; // divisor for response time penalty calculation
54
- this.failurePenaltyMultiplier = 15; // penalty per consecutive failure
43
+ // Initialize configurable thresholds from config
44
+ this.responseTimeThreshold = healthConfig.responseTimeThreshold ?? DEFAULT_HEALTH_CONFIG.responseTimeThreshold;
45
+ this.responseTimePenaltyDivisor = healthConfig.responseTimePenaltyDivisor ?? DEFAULT_HEALTH_CONFIG.responseTimePenaltyDivisor;
46
+ this.failurePenaltyMultiplier = healthConfig.failurePenaltyMultiplier ?? DEFAULT_HEALTH_CONFIG.failurePenaltyMultiplier;
47
+ this.minRequestsForReliableScore = healthConfig.minRequestsForReliableScore ?? DEFAULT_HEALTH_CONFIG.minRequestsForReliableScore;
55
48
  this.persistenceDebounceMs = 30000; // 30 seconds debounce for persistence
56
49
  // Load existing state
57
50
  if (this.persistenceEnabled) {
@@ -189,7 +182,7 @@ export class HealthTracker {
189
182
  calculateHealthScore(health) {
190
183
  let score = 100;
191
184
  // Penalize based on success rate
192
- if (health.totalRequests >= MIN_REQUESTS_FOR_RELIABLE_SCORE) {
185
+ if (health.totalRequests >= this.minRequestsForReliableScore) {
193
186
  const successRate = health.successfulRequests / health.totalRequests;
194
187
  score = Math.round(score * successRate);
195
188
  }
@@ -310,7 +303,7 @@ export class HealthTracker {
310
303
  const avgHealthScore = models.length > 0
311
304
  ? Math.round(models.reduce((sum, h) => sum + h.healthScore, 0) / models.length)
312
305
  : 100;
313
- const modelsWithReliableData = models.filter(h => h.totalRequests >= MIN_REQUESTS_FOR_RELIABLE_SCORE).length;
306
+ const modelsWithReliableData = models.filter(h => h.totalRequests >= this.minRequestsForReliableScore).length;
314
307
  return {
315
308
  totalTracked: models.length,
316
309
  totalRequests,
@@ -36,6 +36,14 @@ export declare class RetryManager {
36
36
  * Calculate linear backoff delay
37
37
  */
38
38
  private calculateLinearDelay;
39
+ /**
40
+ * Calculate polynomial backoff delay
41
+ */
42
+ private calculatePolynomialDelay;
43
+ /**
44
+ * Calculate custom backoff delay
45
+ */
46
+ private calculateCustomDelay;
39
47
  /**
40
48
  * Apply jitter to delay
41
49
  */
@@ -26,6 +26,10 @@ export class RetryManager {
26
26
  this.logger.warn('Invalid strategy, using default', { strategy: this.config.strategy });
27
27
  this.config.strategy = DEFAULT_RETRY_POLICY.strategy;
28
28
  }
29
+ if (this.config.strategy === 'custom' && typeof this.config.customStrategy !== 'function') {
30
+ this.logger.warn('Custom strategy selected but customStrategy is not a function, using immediate');
31
+ this.config.strategy = 'immediate';
32
+ }
29
33
  if (this.config.maxRetries < 0) {
30
34
  this.logger.warn('Invalid maxRetries, using default', { maxRetries: this.config.maxRetries });
31
35
  this.config.maxRetries = DEFAULT_RETRY_POLICY.maxRetries;
@@ -46,6 +50,14 @@ export class RetryManager {
46
50
  this.logger.warn('Invalid jitterFactor, using default', { jitterFactor: this.config.jitterFactor });
47
51
  this.config.jitterFactor = DEFAULT_RETRY_POLICY.jitterFactor;
48
52
  }
53
+ if (this.config.polynomialBase !== undefined && this.config.polynomialBase <= 0) {
54
+ this.logger.warn('Invalid polynomialBase, using default', { polynomialBase: this.config.polynomialBase });
55
+ this.config.polynomialBase = DEFAULT_RETRY_POLICY.polynomialBase;
56
+ }
57
+ if (this.config.polynomialExponent !== undefined && this.config.polynomialExponent <= 0) {
58
+ this.logger.warn('Invalid polynomialExponent, using default', { polynomialExponent: this.config.polynomialExponent });
59
+ this.config.polynomialExponent = DEFAULT_RETRY_POLICY.polynomialExponent;
60
+ }
49
61
  if (this.config.timeoutMs !== undefined && this.config.timeoutMs < 0) {
50
62
  this.logger.warn('Invalid timeoutMs, ignoring', { timeoutMs: this.config.timeoutMs });
51
63
  this.config.timeoutMs = undefined;
@@ -96,6 +108,12 @@ export class RetryManager {
96
108
  case "linear":
97
109
  delay = this.calculateLinearDelay(attempt.attemptCount);
98
110
  break;
111
+ case "polynomial":
112
+ delay = this.calculatePolynomialDelay(attempt.attemptCount);
113
+ break;
114
+ case "custom":
115
+ delay = this.calculateCustomDelay(attempt.attemptCount);
116
+ break;
99
117
  case "immediate":
100
118
  default:
101
119
  delay = 0;
@@ -121,6 +139,50 @@ export class RetryManager {
121
139
  const linearDelay = this.config.baseDelayMs * (attemptCount + 1);
122
140
  return Math.min(linearDelay, this.config.maxDelayMs);
123
141
  }
142
+ /**
143
+ * Calculate polynomial backoff delay
144
+ */
145
+ calculatePolynomialDelay(attemptCount) {
146
+ const base = this.config.polynomialBase || 1.5;
147
+ const exponent = this.config.polynomialExponent || 2;
148
+ const polynomialDelay = this.config.baseDelayMs * Math.pow(base, attemptCount * exponent);
149
+ return Math.min(polynomialDelay, this.config.maxDelayMs);
150
+ }
151
+ /**
152
+ * Calculate custom backoff delay
153
+ */
154
+ calculateCustomDelay(attemptCount) {
155
+ if (this.config.customStrategy) {
156
+ try {
157
+ const rawDelay = this.config.customStrategy(attemptCount);
158
+ // Validate and clamp the delay to valid range
159
+ const clampedDelay = Math.max(0, Math.min(rawDelay, this.config.maxDelayMs));
160
+ // Log warnings if value was clamped
161
+ if (rawDelay < 0) {
162
+ this.logger.warn('Custom strategy returned negative delay, clamping to 0', {
163
+ rawDelay,
164
+ attemptCount,
165
+ });
166
+ }
167
+ else if (rawDelay > this.config.maxDelayMs) {
168
+ this.logger.warn('Custom strategy returned delay exceeding maxDelayMs, clamping', {
169
+ rawDelay,
170
+ maxDelayMs: this.config.maxDelayMs,
171
+ attemptCount,
172
+ });
173
+ }
174
+ return clampedDelay;
175
+ }
176
+ catch (error) {
177
+ this.logger.error('Custom strategy function threw error, using immediate', { error, attemptCount });
178
+ return 0;
179
+ }
180
+ }
181
+ else {
182
+ this.logger.warn('Custom strategy selected but no customStrategy function provided, using immediate');
183
+ return 0;
184
+ }
185
+ }
124
186
  /**
125
187
  * Apply jitter to delay
126
188
  */
@@ -19,8 +19,21 @@ export interface FallbackModel {
19
19
  export type FallbackMode = "cycle" | "stop" | "retry-last";
20
20
  /**
21
21
  * Retry strategy type
22
+ * - "immediate": Retry immediately without delay
23
+ * - "exponential": Exponential backoff (baseDelayMs * 2^attempt)
24
+ * - "linear": Linear backoff (baseDelayMs * (attempt + 1))
25
+ * - "polynomial": Polynomial backoff (polynomialBase ^ polynomialExponent * attempt * baseDelayMs)
26
+ * - "custom": Use custom function (TypeScript/JS configuration only, not JSON)
22
27
  */
23
- export type RetryStrategy = "immediate" | "exponential" | "linear" | "custom";
28
+ export type RetryStrategy = "immediate" | "exponential" | "linear" | "polynomial" | "custom";
29
+ /**
30
+ * Custom retry strategy function (for TypeScript/JavaScript configuration only)
31
+ * Note: This only works when configured programmatically in TypeScript/JS, not in JSON files.
32
+ * For JSON configuration, use named strategies like "polynomial".
33
+ * @param attemptCount - The current attempt count (0-indexed)
34
+ * @returns The delay in milliseconds before the next retry
35
+ */
36
+ export type CustomRetryStrategyFn = (attemptCount: number) => number;
24
37
  /**
25
38
  * Retry policy configuration
26
39
  */
@@ -32,6 +45,9 @@ export interface RetryPolicy {
32
45
  jitterEnabled: boolean;
33
46
  jitterFactor: number;
34
47
  timeoutMs?: number;
48
+ polynomialBase?: number;
49
+ polynomialExponent?: number;
50
+ customStrategy?: CustomRetryStrategyFn;
35
51
  }
36
52
  /**
37
53
  * Circuit breaker state
@@ -82,12 +98,21 @@ export interface ConfigValidationOptions {
82
98
  logWarnings?: boolean;
83
99
  }
84
100
  /**
85
- * Health persistence configuration
101
+ * Health tracker configuration
86
102
  */
87
- export interface HealthPersistenceConfig {
103
+ export interface HealthTrackerConfig {
88
104
  enabled: boolean;
89
105
  path?: string;
106
+ responseTimeThreshold?: number;
107
+ responseTimePenaltyDivisor?: number;
108
+ failurePenaltyMultiplier?: number;
109
+ minRequestsForReliableScore?: number;
90
110
  }
111
+ /**
112
+ * Health persistence configuration (alias for HealthTrackerConfig)
113
+ * Use this for backward compatibility.
114
+ */
115
+ export type HealthPersistenceConfig = HealthTrackerConfig;
91
116
  /**
92
117
  * Health metrics for a model
93
118
  */
@@ -22,6 +22,8 @@ export const DEFAULT_RETRY_POLICY = {
22
22
  maxDelayMs: 30000,
23
23
  jitterEnabled: false,
24
24
  jitterFactor: 0.1,
25
+ polynomialBase: 1.5,
26
+ polynomialExponent: 2,
25
27
  };
26
28
  /**
27
29
  * Default circuit breaker configuration
@@ -40,7 +42,7 @@ export const VALID_FALLBACK_MODES = ["cycle", "stop", "retry-last"];
40
42
  /**
41
43
  * Valid retry strategies
42
44
  */
43
- export const VALID_RETRY_STRATEGIES = ["immediate", "exponential", "linear", "custom"];
45
+ export const VALID_RETRY_STRATEGIES = ["immediate", "exponential", "linear", "polynomial", "custom"];
44
46
  /**
45
47
  * Valid reset intervals
46
48
  */
@@ -4,29 +4,20 @@
4
4
  import { existsSync, readFileSync } from "fs";
5
5
  import { join } from "path";
6
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';
7
+ import { DEFAULT_HEALTH_TRACKER_CONFIG, DEFAULT_COOLDOWN_MS, DEFAULT_FALLBACK_MODE, DEFAULT_LOG_CONFIG, DEFAULT_METRICS_CONFIG, } from '../config/defaults.js';
7
8
  /**
8
9
  * Default plugin configuration
9
10
  */
10
11
  export const DEFAULT_CONFIG = {
11
12
  fallbackModels: DEFAULT_FALLBACK_MODELS,
12
- cooldownMs: 60 * 1000,
13
+ cooldownMs: DEFAULT_COOLDOWN_MS,
13
14
  enabled: true,
14
- fallbackMode: "cycle",
15
+ fallbackMode: DEFAULT_FALLBACK_MODE,
15
16
  retryPolicy: DEFAULT_RETRY_POLICY,
16
17
  circuitBreaker: DEFAULT_CIRCUIT_BREAKER_CONFIG,
17
- log: {
18
- level: "warn",
19
- format: "simple",
20
- enableTimestamp: true,
21
- },
22
- metrics: {
23
- enabled: false,
24
- output: {
25
- console: true,
26
- format: "pretty",
27
- },
28
- resetInterval: "daily",
29
- },
18
+ healthPersistence: DEFAULT_HEALTH_TRACKER_CONFIG,
19
+ log: DEFAULT_LOG_CONFIG,
20
+ metrics: DEFAULT_METRICS_CONFIG,
30
21
  };
31
22
  /**
32
23
  * Validate configuration values
@@ -49,6 +40,10 @@ export function validateConfig(config) {
49
40
  ...DEFAULT_CONFIG.circuitBreaker,
50
41
  ...config.circuitBreaker,
51
42
  } : DEFAULT_CONFIG.circuitBreaker,
43
+ healthPersistence: config.healthPersistence ? {
44
+ ...DEFAULT_CONFIG.healthPersistence,
45
+ ...config.healthPersistence,
46
+ } : DEFAULT_CONFIG.healthPersistence,
52
47
  log: config.log ? { ...DEFAULT_CONFIG.log, ...config.log } : DEFAULT_CONFIG.log,
53
48
  metrics: config.metrics ? {
54
49
  ...DEFAULT_CONFIG.metrics,
@@ -89,10 +84,8 @@ export function loadConfig(directory, worktree) {
89
84
  const userConfig = JSON.parse(content);
90
85
  return { config: validateConfig(userConfig), source: configPath };
91
86
  }
92
- catch (error) {
93
- // Log config errors to console immediately before logger is initialized
94
- const errorMessage = error instanceof Error ? error.message : String(error);
95
- console.error(`[RateLimitFallback] Failed to load config from ${configPath}:`, errorMessage);
87
+ catch {
88
+ // Skip invalid config files silently - caller will log via structured logger
96
89
  }
97
90
  }
98
91
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azumag/opencode-rate-limit-fallback",
3
- "version": "1.37.0",
3
+ "version": "1.40.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",