@azumag/opencode-rate-limit-fallback 1.21.0 → 1.21.2

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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Type definitions for Rate Limit Fallback Plugin
3
+ */
4
+ import type { LogConfig } from '../../logger.js';
5
+ import type { TextPartInput, FilePartInput } from "@opencode-ai/sdk";
6
+ /**
7
+ * Represents a fallback model configuration
8
+ */
9
+ export interface FallbackModel {
10
+ providerID: string;
11
+ modelID: string;
12
+ }
13
+ /**
14
+ * Fallback mode when all models are exhausted:
15
+ * - "cycle": Reset and retry from the first model (default)
16
+ * - "stop": Stop and show error message
17
+ * - "retry-last": Try the last model once, then reset to first on next prompt
18
+ */
19
+ export type FallbackMode = "cycle" | "stop" | "retry-last";
20
+ /**
21
+ * Metrics output configuration
22
+ */
23
+ export interface MetricsOutputConfig {
24
+ console: boolean;
25
+ file?: string;
26
+ format: "pretty" | "json" | "csv";
27
+ }
28
+ /**
29
+ * Metrics configuration
30
+ */
31
+ export interface MetricsConfig {
32
+ enabled: boolean;
33
+ output: MetricsOutputConfig;
34
+ resetInterval: "hourly" | "daily" | "weekly";
35
+ }
36
+ /**
37
+ * Plugin configuration
38
+ */
39
+ export interface PluginConfig {
40
+ fallbackModels: FallbackModel[];
41
+ cooldownMs: number;
42
+ enabled: boolean;
43
+ fallbackMode: FallbackMode;
44
+ maxSubagentDepth?: number;
45
+ enableSubagentFallback?: boolean;
46
+ log?: LogConfig;
47
+ metrics?: MetricsConfig;
48
+ }
49
+ /**
50
+ * Fallback state for tracking progress
51
+ */
52
+ export type FallbackState = "none" | "in_progress" | "completed";
53
+ /**
54
+ * Subagent session information
55
+ */
56
+ export interface SubagentSession {
57
+ sessionID: string;
58
+ parentSessionID: string;
59
+ depth: number;
60
+ fallbackState: FallbackState;
61
+ createdAt: number;
62
+ lastActivity: number;
63
+ }
64
+ /**
65
+ * Session hierarchy for managing subagents
66
+ */
67
+ export interface SessionHierarchy {
68
+ rootSessionID: string;
69
+ subagents: Map<string, SubagentSession>;
70
+ sharedFallbackState: FallbackState;
71
+ sharedConfig: PluginConfig;
72
+ createdAt: number;
73
+ lastActivity: number;
74
+ }
75
+ /**
76
+ * Session error event properties
77
+ */
78
+ export interface SessionErrorEventProperties {
79
+ sessionID: string;
80
+ error: unknown;
81
+ }
82
+ /**
83
+ * Message updated event info
84
+ */
85
+ export interface MessageUpdatedEventInfo {
86
+ sessionID: string;
87
+ providerID?: string;
88
+ modelID?: string;
89
+ error?: unknown;
90
+ id?: string;
91
+ status?: string;
92
+ role?: string;
93
+ [key: string]: unknown;
94
+ }
95
+ /**
96
+ * Message updated event properties
97
+ */
98
+ export interface MessageUpdatedEventProperties {
99
+ info: MessageUpdatedEventInfo;
100
+ [key: string]: unknown;
101
+ }
102
+ /**
103
+ * Session retry status
104
+ */
105
+ export interface SessionRetryStatus {
106
+ type: string;
107
+ message: string;
108
+ [key: string]: unknown;
109
+ }
110
+ /**
111
+ * Session status event properties
112
+ */
113
+ export interface SessionStatusEventProperties {
114
+ sessionID: string;
115
+ status?: SessionRetryStatus;
116
+ [key: string]: unknown;
117
+ }
118
+ /**
119
+ * Rate limit metrics for a model
120
+ */
121
+ export interface RateLimitMetrics {
122
+ count: number;
123
+ lastOccurrence: number;
124
+ firstOccurrence: number;
125
+ averageInterval?: number;
126
+ }
127
+ /**
128
+ * Fallback target metrics
129
+ */
130
+ export interface FallbackTargetMetrics {
131
+ usedAsFallback: number;
132
+ successful: number;
133
+ failed: number;
134
+ }
135
+ /**
136
+ * Model performance metrics
137
+ */
138
+ export interface ModelPerformanceMetrics {
139
+ requests: number;
140
+ successes: number;
141
+ failures: number;
142
+ averageResponseTime?: number;
143
+ }
144
+ /**
145
+ * Complete metrics data
146
+ */
147
+ export interface MetricsData {
148
+ rateLimits: Map<string, RateLimitMetrics>;
149
+ fallbacks: {
150
+ total: number;
151
+ successful: number;
152
+ failed: number;
153
+ averageDuration: number;
154
+ byTargetModel: Map<string, FallbackTargetMetrics>;
155
+ };
156
+ modelPerformance: Map<string, ModelPerformanceMetrics>;
157
+ startedAt: number;
158
+ generatedAt: number;
159
+ }
160
+ /**
161
+ * Text message part
162
+ */
163
+ export type TextPart = {
164
+ type: "text";
165
+ text: string;
166
+ };
167
+ /**
168
+ * File message part
169
+ */
170
+ export type FilePart = {
171
+ type: "file";
172
+ path: string;
173
+ mediaType: string;
174
+ };
175
+ /**
176
+ * Message part (text or file)
177
+ */
178
+ export type MessagePart = TextPart | FilePart;
179
+ /**
180
+ * SDK-compatible message part input
181
+ */
182
+ export type SDKMessagePartInput = TextPartInput | FilePartInput;
183
+ /**
184
+ * OpenCode client interface
185
+ */
186
+ export type OpenCodeClient = {
187
+ session: {
188
+ abort: (args: {
189
+ path: {
190
+ id: string;
191
+ };
192
+ }) => Promise<unknown>;
193
+ messages: (args: {
194
+ path: {
195
+ id: string;
196
+ };
197
+ }) => Promise<{
198
+ data?: Array<{
199
+ info: {
200
+ id: string;
201
+ role: string;
202
+ };
203
+ parts: unknown[];
204
+ }>;
205
+ }>;
206
+ prompt: (args: {
207
+ path: {
208
+ id: string;
209
+ };
210
+ body: {
211
+ parts: SDKMessagePartInput[];
212
+ model: {
213
+ providerID: string;
214
+ modelID: string;
215
+ };
216
+ };
217
+ }) => Promise<unknown>;
218
+ };
219
+ tui?: {
220
+ showToast: (toast: any) => Promise<any>;
221
+ };
222
+ };
223
+ /**
224
+ * Plugin context
225
+ */
226
+ export type PluginContext = {
227
+ client: OpenCodeClient;
228
+ directory: string;
229
+ };
230
+ /**
231
+ * Default fallback models
232
+ */
233
+ export declare const DEFAULT_FALLBACK_MODELS: FallbackModel[];
234
+ /**
235
+ * Valid fallback modes
236
+ */
237
+ export declare const VALID_FALLBACK_MODES: FallbackMode[];
238
+ /**
239
+ * Valid reset intervals
240
+ */
241
+ export declare const VALID_RESET_INTERVALS: readonly ["hourly", "daily", "weekly"];
242
+ export type ResetInterval = typeof VALID_RESET_INTERVALS[number];
243
+ /**
244
+ * Reset interval values in milliseconds
245
+ */
246
+ export declare const RESET_INTERVAL_MS: Record<ResetInterval, number>;
247
+ /**
248
+ * Deduplication window for fallback processing
249
+ */
250
+ export declare const DEDUP_WINDOW_MS = 5000;
251
+ /**
252
+ * State timeout for retry state
253
+ */
254
+ export declare const STATE_TIMEOUT_MS = 30000;
255
+ /**
256
+ * Cleanup interval for stale entries
257
+ */
258
+ export declare const CLEANUP_INTERVAL_MS = 300000;
259
+ /**
260
+ * TTL for session entries
261
+ */
262
+ export declare const SESSION_ENTRY_TTL_MS = 3600000;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Type definitions for Rate Limit Fallback Plugin
3
+ */
4
+ // ============================================================================
5
+ // Constants
6
+ // ============================================================================
7
+ /**
8
+ * Default fallback models
9
+ */
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
+ ];
15
+ /**
16
+ * Valid fallback modes
17
+ */
18
+ export const VALID_FALLBACK_MODES = ["cycle", "stop", "retry-last"];
19
+ /**
20
+ * Valid reset intervals
21
+ */
22
+ export const VALID_RESET_INTERVALS = ["hourly", "daily", "weekly"];
23
+ /**
24
+ * Reset interval values in milliseconds
25
+ */
26
+ export const RESET_INTERVAL_MS = {
27
+ hourly: 60 * 60 * 1000,
28
+ daily: 24 * 60 * 60 * 1000,
29
+ weekly: 7 * 24 * 60 * 60 * 1000,
30
+ };
31
+ /**
32
+ * Deduplication window for fallback processing
33
+ */
34
+ export const DEDUP_WINDOW_MS = 5000;
35
+ /**
36
+ * State timeout for retry state
37
+ */
38
+ export const STATE_TIMEOUT_MS = 30000;
39
+ /**
40
+ * Cleanup interval for stale entries
41
+ */
42
+ export const CLEANUP_INTERVAL_MS = 300000; // 5 minutes
43
+ /**
44
+ * TTL for session entries
45
+ */
46
+ export const SESSION_ENTRY_TTL_MS = 3600000; // 1 hour
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Configuration loading and validation
3
+ */
4
+ import type { PluginConfig } from '../types/index.js';
5
+ /**
6
+ * Default plugin configuration
7
+ */
8
+ export declare const DEFAULT_CONFIG: PluginConfig;
9
+ /**
10
+ * Validate configuration values
11
+ */
12
+ export declare function validateConfig(config: any): PluginConfig;
13
+ /**
14
+ * Load and validate config from file paths
15
+ */
16
+ export declare function loadConfig(directory: string): PluginConfig;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Configuration loading and validation
3
+ */
4
+ import { existsSync, readFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { DEFAULT_FALLBACK_MODELS, VALID_FALLBACK_MODES, VALID_RESET_INTERVALS, } from '../types/index.js';
7
+ /**
8
+ * Default plugin configuration
9
+ */
10
+ export const DEFAULT_CONFIG = {
11
+ fallbackModels: DEFAULT_FALLBACK_MODELS,
12
+ cooldownMs: 60 * 1000,
13
+ enabled: true,
14
+ fallbackMode: "cycle",
15
+ log: {
16
+ level: "warn",
17
+ format: "simple",
18
+ enableTimestamp: true,
19
+ },
20
+ metrics: {
21
+ enabled: false,
22
+ output: {
23
+ console: true,
24
+ format: "pretty",
25
+ },
26
+ resetInterval: "daily",
27
+ },
28
+ };
29
+ /**
30
+ * Validate configuration values
31
+ */
32
+ export function validateConfig(config) {
33
+ const mode = config.fallbackMode;
34
+ const resetInterval = config.metrics?.resetInterval;
35
+ return {
36
+ ...DEFAULT_CONFIG,
37
+ ...config,
38
+ fallbackModels: config.fallbackModels || DEFAULT_CONFIG.fallbackModels,
39
+ fallbackMode: VALID_FALLBACK_MODES.includes(mode) ? mode : DEFAULT_CONFIG.fallbackMode,
40
+ log: config.log ? { ...DEFAULT_CONFIG.log, ...config.log } : DEFAULT_CONFIG.log,
41
+ metrics: config.metrics ? {
42
+ ...DEFAULT_CONFIG.metrics,
43
+ ...config.metrics,
44
+ output: config.metrics.output ? {
45
+ ...DEFAULT_CONFIG.metrics.output,
46
+ ...config.metrics.output,
47
+ } : DEFAULT_CONFIG.metrics.output,
48
+ resetInterval: VALID_RESET_INTERVALS.includes(resetInterval) ? resetInterval : DEFAULT_CONFIG.metrics.resetInterval,
49
+ } : DEFAULT_CONFIG.metrics,
50
+ };
51
+ }
52
+ /**
53
+ * Load and validate config from file paths
54
+ */
55
+ export function loadConfig(directory) {
56
+ const homedir = process.env.HOME || "";
57
+ const configPaths = [
58
+ join(directory, ".opencode", "rate-limit-fallback.json"),
59
+ join(directory, "rate-limit-fallback.json"),
60
+ join(homedir, ".opencode", "rate-limit-fallback.json"),
61
+ join(homedir, ".config", "opencode", "rate-limit-fallback.json"),
62
+ ];
63
+ for (const configPath of configPaths) {
64
+ if (existsSync(configPath)) {
65
+ try {
66
+ const content = readFileSync(configPath, "utf-8");
67
+ const userConfig = JSON.parse(content);
68
+ return validateConfig(userConfig);
69
+ }
70
+ catch (error) {
71
+ // Log config errors to console immediately before logger is initialized
72
+ const errorMessage = error instanceof Error ? error.message : String(error);
73
+ console.error(`[RateLimitFallback] Failed to load config from ${configPath}:`, errorMessage);
74
+ }
75
+ }
76
+ }
77
+ return DEFAULT_CONFIG;
78
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Rate limit error detection
3
+ */
4
+ /**
5
+ * Check if error is rate limit related
6
+ */
7
+ export declare function isRateLimitError(error: unknown): boolean;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Rate limit error detection
3
+ */
4
+ /**
5
+ * Check if error is rate limit related
6
+ */
7
+ export function isRateLimitError(error) {
8
+ if (!error || typeof error !== "object")
9
+ return false;
10
+ // More type-safe error object structure
11
+ const err = error;
12
+ // Check for 429 status code in APIError (strict check)
13
+ if (err.name === "APIError" && err.data?.statusCode === 429) {
14
+ return true;
15
+ }
16
+ // Type-safe access to error fields
17
+ const responseBody = String(err.data?.responseBody || "").toLowerCase();
18
+ const message = String(err.data?.message || err.message || "").toLowerCase();
19
+ // Strict rate limit indicators only - avoid false positives
20
+ const strictRateLimitIndicators = [
21
+ "rate limit",
22
+ "rate_limit",
23
+ "ratelimit",
24
+ "too many requests",
25
+ "quota exceeded",
26
+ ];
27
+ // Check for 429 in text (explicit HTTP status code)
28
+ if (responseBody.includes("429") || message.includes("429")) {
29
+ return true;
30
+ }
31
+ // Check for strict rate limit keywords
32
+ return strictRateLimitIndicators.some((indicator) => responseBody.includes(indicator) ||
33
+ message.includes(indicator));
34
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * General utility functions
3
+ */
4
+ import type { MessagePart, SDKMessagePartInput } from '../types/index.js';
5
+ export declare const DEDUP_WINDOW_MS = 5000;
6
+ export declare const STATE_TIMEOUT_MS = 30000;
7
+ /**
8
+ * Generate a model identifier key
9
+ */
10
+ export declare function getModelKey(providerID: string, modelID: string): string;
11
+ /**
12
+ * Generate a state identifier
13
+ */
14
+ export declare function getStateKey(sessionID: string, messageID: string): string;
15
+ /**
16
+ * Extract and validate message parts from a user message
17
+ */
18
+ export declare function extractMessageParts(message: unknown): MessagePart[];
19
+ /**
20
+ * Convert internal MessagePart to SDK-compatible format
21
+ */
22
+ export declare function convertPartsToSDKFormat(parts: MessagePart[]): SDKMessagePartInput[];
23
+ /**
24
+ * Extract toast message properties with fallback values
25
+ */
26
+ export declare function getToastMessage(toast: any): {
27
+ title: string;
28
+ message: string;
29
+ variant: string;
30
+ };
31
+ /**
32
+ * Safely show toast, falling back to console logging if TUI is missing or fails
33
+ */
34
+ export declare const safeShowToast: (client: any, toast: any) => Promise<void>;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * General utility functions
3
+ */
4
+ import { DEDUP_WINDOW_MS as DEDUP_WINDOW_MS_TYPE, STATE_TIMEOUT_MS as STATE_TIMEOUT_MS_TYPE, } from '../types/index.js';
5
+ // Re-export as constants
6
+ export const DEDUP_WINDOW_MS = DEDUP_WINDOW_MS_TYPE;
7
+ export const STATE_TIMEOUT_MS = STATE_TIMEOUT_MS_TYPE;
8
+ /**
9
+ * Generate a model identifier key
10
+ */
11
+ export function getModelKey(providerID, modelID) {
12
+ return `${providerID}/${modelID}`;
13
+ }
14
+ /**
15
+ * Generate a state identifier
16
+ */
17
+ export function getStateKey(sessionID, messageID) {
18
+ return `${sessionID}:${messageID}`;
19
+ }
20
+ /**
21
+ * Extract and validate message parts from a user message
22
+ */
23
+ export function extractMessageParts(message) {
24
+ const msg = message;
25
+ return msg.parts
26
+ .filter((p) => {
27
+ const part = p;
28
+ return part.type === "text" || part.type === "file";
29
+ })
30
+ .map((p) => {
31
+ const part = p;
32
+ if (part.type === "text")
33
+ return { type: "text", text: String(part.text) };
34
+ if (part.type === "file")
35
+ return { type: "file", path: String(part.path), mediaType: String(part.mediaType) };
36
+ return null;
37
+ })
38
+ .filter((p) => p !== null);
39
+ }
40
+ /**
41
+ * Convert internal MessagePart to SDK-compatible format
42
+ */
43
+ export function convertPartsToSDKFormat(parts) {
44
+ return parts.map((part) => {
45
+ if (part.type === "text") {
46
+ return { type: "text", text: part.text };
47
+ }
48
+ // For file parts, we need to match the FilePartInput format
49
+ // Using path as url since we're dealing with local files
50
+ return {
51
+ type: "file",
52
+ url: part.path,
53
+ mime: part.mediaType || "application/octet-stream",
54
+ };
55
+ });
56
+ }
57
+ /**
58
+ * Extract toast message properties with fallback values
59
+ */
60
+ export function getToastMessage(toast) {
61
+ const title = toast?.body?.title || toast?.title || "Toast";
62
+ const message = toast?.body?.message || toast?.message || "";
63
+ const variant = toast?.body?.variant || toast?.variant || "info";
64
+ return { title, message, variant };
65
+ }
66
+ /**
67
+ * Safely show toast, falling back to console logging if TUI is missing or fails
68
+ */
69
+ export const safeShowToast = async (client, toast) => {
70
+ const { title, message, variant } = getToastMessage(toast);
71
+ const logToConsole = () => {
72
+ if (variant === "error") {
73
+ console.error(`[RateLimitFallback] ${title}: ${message}`);
74
+ }
75
+ else if (variant === "warning") {
76
+ console.warn(`[RateLimitFallback] ${title}: ${message}`);
77
+ }
78
+ else {
79
+ console.log(`[RateLimitFallback] ${title}: ${message}`);
80
+ }
81
+ };
82
+ try {
83
+ if (client.tui) {
84
+ await client.tui.showToast(toast);
85
+ }
86
+ else {
87
+ // TUI doesn't exist - log to console
88
+ logToConsole();
89
+ }
90
+ }
91
+ catch {
92
+ // TUI exists but failed to show toast - log to console
93
+ logToConsole();
94
+ }
95
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azumag/opencode-rate-limit-fallback",
3
- "version": "1.21.0",
3
+ "version": "1.21.2",
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",
@@ -32,10 +32,8 @@
32
32
  "test:coverage": "vitest run --coverage"
33
33
  },
34
34
  "files": [
35
- "dist/index.js",
36
- "dist/index.d.ts",
37
- "dist/logger.js",
38
- "dist/logger.d.ts",
35
+ "dist/**/*.js",
36
+ "dist/**/*.d.ts",
39
37
  "README.md",
40
38
  "LICENSE"
41
39
  ],