diff --git a/src/core/Cline.ts b/src/core/Cline.ts index 0c0bd37..478811c 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -96,6 +96,7 @@ export class Cline { didFinishAborting = false abandoned = false private diffViewProvider: DiffViewProvider + private lastApiRequestTime?: number // streaming private currentStreamingContentIndex = 0 @@ -796,9 +797,40 @@ export class Cline { async *attemptApiRequest(previousApiReqIndex: number, retryAttempt: number = 0): ApiStream { let mcpHub: McpHub | undefined - const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds } = + const { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, rateLimitSeconds } = (await this.providerRef.deref()?.getState()) ?? {} + let finalDelay = 0 + + // Only apply rate limiting if this isn't the first request + if (this.lastApiRequestTime) { + const now = Date.now() + const timeSinceLastRequest = now - this.lastApiRequestTime + const rateLimit = rateLimitSeconds || 0 + const rateLimitDelay = Math.max(0, rateLimit * 1000 - timeSinceLastRequest) + finalDelay = rateLimitDelay + } + + // Add exponential backoff delay for retries + if (retryAttempt > 0) { + const baseDelay = requestDelaySeconds || 5 + const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, retryAttempt)) * 1000 + finalDelay = Math.max(finalDelay, exponentialDelay) + } + + if (finalDelay > 0) { + // Show countdown timer + for (let i = Math.ceil(finalDelay / 1000); i > 0; i--) { + const delayMessage = + retryAttempt > 0 ? `Retrying in ${i} seconds...` : `Rate limiting for ${i} seconds...` + await this.say("api_req_retry_delayed", delayMessage, undefined, true) + await delay(1000) + } + } + + // Update last request time before making the request + this.lastApiRequestTime = Date.now() + if (mcpEnabled ?? true) { mcpHub = this.providerRef.deref()?.mcpHub if (!mcpHub) { diff --git a/src/core/__tests__/Cline.test.ts b/src/core/__tests__/Cline.test.ts index f868d17..e49b660 100644 --- a/src/core/__tests__/Cline.test.ts +++ b/src/core/__tests__/Cline.test.ts @@ -750,8 +750,11 @@ describe("Cline", () => { false, ) - // Verify delay was called correctly - expect(mockDelay).toHaveBeenCalledTimes(baseDelay) + // Calculate expected delay calls based on exponential backoff + const exponentialDelay = Math.ceil(baseDelay * Math.pow(2, 1)) // retryAttempt = 1 + const rateLimitDelay = baseDelay // Initial rate limit delay + const totalExpectedDelays = exponentialDelay + rateLimitDelay + expect(mockDelay).toHaveBeenCalledTimes(totalExpectedDelays) expect(mockDelay).toHaveBeenCalledWith(1000) // Verify error message content diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index ffb9848..139ed90 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -112,6 +112,7 @@ type GlobalStateKey = | "mcpEnabled" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "rateLimitSeconds" | "currentApiConfigName" | "listApiConfigMeta" | "vsCodeLmModelSelector" @@ -886,6 +887,10 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("requestDelaySeconds", message.value ?? 5) await this.postStateToWebview() break + case "rateLimitSeconds": + await this.updateGlobalState("rateLimitSeconds", message.value ?? 0) + await this.postStateToWebview() + break case "preferredLanguage": await this.updateGlobalState("preferredLanguage", message.text) await this.postStateToWebview() @@ -1997,6 +2002,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + rateLimitSeconds, currentApiConfigName, listApiConfigMeta, mode, @@ -2038,6 +2044,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: requestDelaySeconds ?? 10, + rateLimitSeconds: rateLimitSeconds ?? 0, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], mode: mode ?? defaultModeSlug, @@ -2161,6 +2168,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled, alwaysApproveResubmit, requestDelaySeconds, + rateLimitSeconds, currentApiConfigName, listApiConfigMeta, vsCodeLmModelSelector, @@ -2233,6 +2241,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("mcpEnabled") as Promise, this.getGlobalState("alwaysApproveResubmit") as Promise, this.getGlobalState("requestDelaySeconds") as Promise, + this.getGlobalState("rateLimitSeconds") as Promise, this.getGlobalState("currentApiConfigName") as Promise, this.getGlobalState("listApiConfigMeta") as Promise, this.getGlobalState("vsCodeLmModelSelector") as Promise, @@ -2355,6 +2364,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mcpEnabled: mcpEnabled ?? true, alwaysApproveResubmit: alwaysApproveResubmit ?? false, requestDelaySeconds: Math.max(5, requestDelaySeconds ?? 10), + rateLimitSeconds: rateLimitSeconds ?? 0, currentApiConfigName: currentApiConfigName ?? "default", listApiConfigMeta: listApiConfigMeta ?? [], modeApiConfigs: modeApiConfigs ?? ({} as Record), diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index 2538e7e..a96cf87 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -324,6 +324,7 @@ describe("ClineProvider", () => { fuzzyMatchThreshold: 1.0, mcpEnabled: true, requestDelaySeconds: 5, + rateLimitSeconds: 0, mode: defaultModeSlug, customModes: [], experiments: experimentDefault, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index a226beb..7eacfca 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -94,6 +94,7 @@ export interface ExtensionState { alwaysApproveResubmit?: boolean alwaysAllowModeSwitch?: boolean requestDelaySeconds: number + rateLimitSeconds: number // Minimum time between successive requests (0 = disabled) uriScheme?: string allowedCommands?: string[] soundEnabled?: boolean diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 357a237..719d25a 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -66,6 +66,7 @@ export interface WebviewMessage { | "refreshGlamaModels" | "alwaysApproveResubmit" | "requestDelaySeconds" + | "rateLimitSeconds" | "setApiConfigPassword" | "requestVsCodeLmModels" | "mode" diff --git a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx index 1bfbc08..491a917 100644 --- a/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx +++ b/webview-ui/src/components/chat/__tests__/AutoApproveMenu.test.tsx @@ -28,6 +28,7 @@ describe("AutoApproveMenu", () => { terminalOutputLineLimit: 500, mcpEnabled: true, requestDelaySeconds: 5, + rateLimitSeconds: 0, currentApiConfigName: "default", listApiConfigMeta: [], mode: defaultModeSlug, @@ -78,6 +79,7 @@ describe("AutoApproveMenu", () => { setMcpEnabled: jest.fn(), setAlwaysApproveResubmit: jest.fn(), setRequestDelaySeconds: jest.fn(), + setRateLimitSeconds: jest.fn(), setCurrentApiConfigName: jest.fn(), setListApiConfigMeta: jest.fn(), onUpdateApiConfig: jest.fn(), diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 187d4cb..c81c372 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -53,6 +53,8 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { setAlwaysApproveResubmit, requestDelaySeconds, setRequestDelaySeconds, + rateLimitSeconds, + setRateLimitSeconds, currentApiConfigName, listApiConfigMeta, experiments, @@ -92,6 +94,7 @@ const SettingsView = ({ onDone }: SettingsViewProps) => { vscode.postMessage({ type: "mcpEnabled", bool: mcpEnabled }) vscode.postMessage({ type: "alwaysApproveResubmit", bool: alwaysApproveResubmit }) vscode.postMessage({ type: "requestDelaySeconds", value: requestDelaySeconds }) + vscode.postMessage({ type: "rateLimitSeconds", value: rateLimitSeconds }) vscode.postMessage({ type: "currentApiConfigName", text: currentApiConfigName }) vscode.postMessage({ type: "upsertApiConfiguration", @@ -572,6 +575,26 @@ const SettingsView = ({ onDone }: SettingsViewProps) => {

Advanced Settings

+
+
+ Rate limit +
+ setRateLimitSeconds(parseInt(e.target.value))} + style={{ ...sliderStyle }} + /> + {rateLimitSeconds}s +
+
+

+ Minimum time between API requests. +

+
Terminal output limit diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 9738edf..7adf500 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -55,6 +55,8 @@ export interface ExtensionStateContextType extends ExtensionState { setAlwaysApproveResubmit: (value: boolean) => void requestDelaySeconds: number setRequestDelaySeconds: (value: number) => void + rateLimitSeconds: number + setRateLimitSeconds: (value: number) => void setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ApiConfigMeta[]) => void onUpdateApiConfig: (apiConfig: ApiConfiguration) => void @@ -92,6 +94,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode mcpEnabled: true, alwaysApproveResubmit: false, requestDelaySeconds: 5, + rateLimitSeconds: 0, // Minimum time between successive requests (0 = disabled) currentApiConfigName: "default", listApiConfigMeta: [], mode: defaultModeSlug, @@ -271,6 +274,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), setAlwaysApproveResubmit: (value) => setState((prevState) => ({ ...prevState, alwaysApproveResubmit: value })), setRequestDelaySeconds: (value) => setState((prevState) => ({ ...prevState, requestDelaySeconds: value })), + setRateLimitSeconds: (value) => setState((prevState) => ({ ...prevState, rateLimitSeconds: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), setListApiConfigMeta, onUpdateApiConfig,