Просмотр исходного кода

Merge pull request #688 from RooVetGit/rate_limiting

Add optional rate limiting between API calls
Matt Rubens 11 месяцев назад
Родитель
Сommit
e8f0b35860

+ 33 - 1
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) {

+ 5 - 2
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

+ 10 - 0
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<boolean | undefined>,
 			this.getGlobalState("alwaysApproveResubmit") as Promise<boolean | undefined>,
 			this.getGlobalState("requestDelaySeconds") as Promise<number | undefined>,
+			this.getGlobalState("rateLimitSeconds") as Promise<number | undefined>,
 			this.getGlobalState("currentApiConfigName") as Promise<string | undefined>,
 			this.getGlobalState("listApiConfigMeta") as Promise<ApiConfigMeta[] | undefined>,
 			this.getGlobalState("vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
@@ -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<Mode, string>),

+ 1 - 0
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,

+ 1 - 0
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

+ 1 - 0
src/shared/WebviewMessage.ts

@@ -66,6 +66,7 @@ export interface WebviewMessage {
 		| "refreshGlamaModels"
 		| "alwaysApproveResubmit"
 		| "requestDelaySeconds"
+		| "rateLimitSeconds"
 		| "setApiConfigPassword"
 		| "requestVsCodeLmModels"
 		| "mode"

+ 2 - 0
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(),

+ 23 - 0
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) => {
 
 				<div style={{ marginBottom: 40 }}>
 					<h3 style={{ color: "var(--vscode-foreground)", margin: "0 0 15px 0" }}>Advanced Settings</h3>
+					<div style={{ marginBottom: 15 }}>
+						<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
+							<span style={{ fontWeight: "500" }}>Rate limit</span>
+							<div style={{ display: "flex", alignItems: "center", gap: "5px" }}>
+								<input
+									type="range"
+									min="0"
+									max="60"
+									step="1"
+									value={rateLimitSeconds}
+									onChange={(e) => setRateLimitSeconds(parseInt(e.target.value))}
+									style={{ ...sliderStyle }}
+								/>
+								<span style={{ ...sliderLabelStyle }}>{rateLimitSeconds}s</span>
+							</div>
+						</div>
+						<p style={{ fontSize: "12px", marginTop: "5px", color: "var(--vscode-descriptionForeground)" }}>
+							Minimum time between API requests.
+						</p>
+					</div>
 					<div style={{ marginBottom: 15 }}>
 						<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
 							<span style={{ fontWeight: "500" }}>Terminal output limit</span>

+ 4 - 0
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,