Răsfoiți Sursa

Fix rate limit wait display (#10389)

Co-authored-by: Roo Code <[email protected]>
Co-authored-by: Matt Rubens <[email protected]>
Hannes Rudolph 13 ore în urmă
părinte
comite
ca1bc18a21

+ 1 - 0
packages/evals/src/cli/runTask.ts

@@ -301,6 +301,7 @@ export const runTask = async ({ run, task, publish, logger, jobToken }: RunTaskO
 		"diff_error",
 		"condense_context",
 		"condense_context_error",
+		"api_req_rate_limit_wait",
 		"api_req_retry_delayed",
 		"api_req_retried",
 	]

+ 2 - 0
packages/types/src/message.ts

@@ -129,6 +129,7 @@ export function isNonBlockingAsk(ask: ClineAsk): ask is NonBlockingAsk {
  * - `api_req_finished`: Indicates an API request has completed successfully
  * - `api_req_retried`: Indicates an API request is being retried after a failure
  * - `api_req_retry_delayed`: Indicates an API request retry has been delayed
+ * - `api_req_rate_limit_wait`: Indicates a configured rate-limit wait (not an error)
  * - `api_req_deleted`: Indicates an API request has been deleted/cancelled
  * - `text`: General text message or assistant response
  * - `reasoning`: Assistant's reasoning or thought process (often hidden from user)
@@ -155,6 +156,7 @@ export const clineSays = [
 	"api_req_finished",
 	"api_req_retried",
 	"api_req_retry_delayed",
+	"api_req_rate_limit_wait",
 	"api_req_deleted",
 	"text",
 	"image",

+ 58 - 22
src/core/task/Task.ts

@@ -2347,6 +2347,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			const modelId = getModelId(this.apiConfiguration)
 			const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId)
 
+			// Respect user-configured provider rate limiting BEFORE we emit api_req_started.
+			// This prevents the UI from showing an "API Request..." spinner while we are
+			// intentionally waiting due to the rate limit slider.
+			//
+			// NOTE: We also set Task.lastGlobalApiRequestTime here to reserve this slot
+			// before we build environment details (which can take time).
+			// This ensures subsequent requests (including subtasks) still honour the
+			// provider rate-limit window.
+			await this.maybeWaitForProviderRateLimit(currentItem.retryAttempt ?? 0)
+			Task.lastGlobalApiRequestTime = performance.now()
+
 			await this.say(
 				"api_req_started",
 				JSON.stringify({
@@ -2554,7 +2565,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				// Yields only if the first chunk is successful, otherwise will
 				// allow the user to retry the request (most likely due to rate
 				// limit error, which gets thrown on the first chunk).
-				const stream = this.attemptApiRequest()
+				const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true })
 				let assistantMessage = ""
 				let reasoningMessage = ""
 				let pendingGroundingSources: GroundingSource[] = []
@@ -3656,7 +3667,44 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId })
 	}
 
-	public async *attemptApiRequest(retryAttempt: number = 0): ApiStream {
+	/**
+	 * Enforce the user-configured provider rate limit.
+	 *
+	 * NOTE: This is intentionally treated as expected behavior and is surfaced via
+	 * the `api_req_rate_limit_wait` say type (not an error).
+	 */
+	private async maybeWaitForProviderRateLimit(retryAttempt: number): Promise<void> {
+		const state = await this.providerRef.deref()?.getState()
+		const rateLimitSeconds =
+			state?.apiConfiguration?.rateLimitSeconds ?? this.apiConfiguration?.rateLimitSeconds ?? 0
+
+		if (rateLimitSeconds <= 0 || !Task.lastGlobalApiRequestTime) {
+			return
+		}
+
+		const now = performance.now()
+		const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime
+		const rateLimitDelay = Math.ceil(
+			Math.min(rateLimitSeconds, Math.max(0, rateLimitSeconds * 1000 - timeSinceLastRequest) / 1000),
+		)
+
+		// Only show the countdown UX on the first attempt. Retry flows have their own delay messaging.
+		if (rateLimitDelay > 0 && retryAttempt === 0) {
+			for (let i = rateLimitDelay; i > 0; i--) {
+				// Send structured JSON data for i18n-safe transport
+				const delayMessage = JSON.stringify({ seconds: i })
+				await this.say("api_req_rate_limit_wait", delayMessage, undefined, true)
+				await delay(1000)
+			}
+			// Finalize the partial message so the UI doesn't keep rendering an in-progress spinner.
+			await this.say("api_req_rate_limit_wait", undefined, undefined, false)
+		}
+	}
+
+	public async *attemptApiRequest(
+		retryAttempt: number = 0,
+		options: { skipProviderRateLimit?: boolean } = {},
+	): ApiStream {
 		const state = await this.providerRef.deref()?.getState()
 
 		const {
@@ -3693,29 +3741,17 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			}
 		}
 
-		let rateLimitDelay = 0
-
-		// Use the shared timestamp so that subtasks respect the same rate-limit
-		// window as their parent tasks.
-		if (Task.lastGlobalApiRequestTime) {
-			const now = performance.now()
-			const timeSinceLastRequest = now - Task.lastGlobalApiRequestTime
-			const rateLimit = apiConfiguration?.rateLimitSeconds || 0
-			rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - timeSinceLastRequest) / 1000))
-		}
-
-		// Only show rate limiting message if we're not retrying. If retrying, we'll include the delay there.
-		if (rateLimitDelay > 0 && retryAttempt === 0) {
-			// Show countdown timer
-			for (let i = rateLimitDelay; i > 0; i--) {
-				const delayMessage = `Rate limiting for ${i} seconds...`
-				await this.say("api_req_retry_delayed", delayMessage, undefined, true)
-				await delay(1000)
-			}
+		if (!options.skipProviderRateLimit) {
+			await this.maybeWaitForProviderRateLimit(retryAttempt)
 		}
 
-		// Update last request time before making the request so that subsequent
+		// Update last request time right before making the request so that subsequent
 		// requests — even from new subtasks — will honour the provider's rate-limit.
+		//
+		// NOTE: When recursivelyMakeClineRequests handles rate limiting, it sets the
+		// timestamp earlier to include the environment details build. We still set it
+		// here for direct callers (tests) and for the case where we didn't rate-limit
+		// in the caller.
 		Task.lastGlobalApiRequestTime = performance.now()
 
 		const systemPrompt = await this.getSystemPrompt()

+ 14 - 0
src/core/task/__tests__/Task.spec.ts

@@ -1041,6 +1041,9 @@ describe("Cline", () => {
 					startTask: false,
 				})
 
+				// Spy on child.say to verify the emitted message type
+				const saySpy = vi.spyOn(child, "say")
+
 				// Mock the child's API stream
 				const childMockStream = {
 					async *[Symbol.asyncIterator]() {
@@ -1067,6 +1070,17 @@ describe("Cline", () => {
 				// Verify rate limiting was applied
 				expect(mockDelay).toHaveBeenCalledTimes(mockApiConfig.rateLimitSeconds)
 				expect(mockDelay).toHaveBeenCalledWith(1000)
+
+				// Verify we used the non-error rate-limit wait message type (JSON format)
+				expect(saySpy).toHaveBeenCalledWith(
+					"api_req_rate_limit_wait",
+					expect.stringMatching(/\{"seconds":\d+\}/),
+					undefined,
+					true,
+				)
+
+				// Verify the wait message was finalized
+				expect(saySpy).toHaveBeenCalledWith("api_req_rate_limit_wait", undefined, undefined, false)
 			}, 10000) // Increase timeout to 10 seconds
 
 			it("should not apply rate limiting if enough time has passed", async () => {

+ 45 - 2
webview-ui/src/components/chat/ChatRow.tsx

@@ -298,6 +298,8 @@ export const ChatRowContent = ({
 						style={{ color: successColor, marginBottom: "-1.5px" }}></span>,
 					<span style={{ color: successColor, fontWeight: "bold" }}>{t("chat:taskCompleted")}</span>,
 				]
+			case "api_req_rate_limit_wait":
+				return []
 			case "api_req_retry_delayed":
 				return []
 			case "api_req_started":
@@ -327,8 +329,10 @@ export const ChatRowContent = ({
 						getIconSpan("arrow-swap", normalColor)
 					) : apiRequestFailedMessage ? (
 						getIconSpan("error", errorColor)
-					) : (
+					) : isLast ? (
 						<ProgressIndicator />
+					) : (
+						getIconSpan("arrow-swap", normalColor)
 					),
 					apiReqCancelReason !== null && apiReqCancelReason !== undefined ? (
 						apiReqCancelReason === "user_cancelled" ? (
@@ -356,7 +360,17 @@ export const ChatRowContent = ({
 			default:
 				return [null, null]
 		}
-	}, [type, isCommandExecuting, message, isMcpServerResponding, apiReqCancelReason, cost, apiRequestFailedMessage, t])
+	}, [
+		type,
+		isCommandExecuting,
+		message,
+		isMcpServerResponding,
+		apiReqCancelReason,
+		cost,
+		apiRequestFailedMessage,
+		t,
+		isLast,
+	])
 
 	const headerStyle: React.CSSProperties = {
 		display: "flex",
@@ -1149,6 +1163,35 @@ export const ChatRowContent = ({
 							errorDetails={rawError}
 						/>
 					)
+				case "api_req_rate_limit_wait": {
+					const isWaiting = message.partial === true
+
+					const waitSeconds = (() => {
+						if (!message.text) return undefined
+						try {
+							const data = JSON.parse(message.text)
+							return typeof data.seconds === "number" ? data.seconds : undefined
+						} catch {
+							return undefined
+						}
+					})()
+
+					return isWaiting && waitSeconds !== undefined ? (
+						<div
+							className={`group text-sm transition-opacity opacity-100`}
+							style={{
+								...headerStyle,
+								marginBottom: 0,
+								justifyContent: "space-between",
+							}}>
+							<div style={{ display: "flex", alignItems: "center", gap: "10px", flexGrow: 1 }}>
+								<ProgressIndicator />
+								<span style={{ color: normalColor }}>{t("chat:apiRequest.rateLimitWait")}</span>
+							</div>
+							<span className="text-xs font-light text-vscode-descriptionForeground">{waitSeconds}s</span>
+						</div>
+					) : null
+				}
 				case "api_req_finished":
 					return null // we should never see this message type
 				case "text":

+ 2 - 0
webview-ui/src/components/chat/ChatView.tsx

@@ -400,6 +400,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 					// an "ask" while ask is waiting for response.
 					switch (lastMessage.say) {
 						case "api_req_retry_delayed":
+						case "api_req_rate_limit_wait":
 							setSendingDisabled(true)
 							break
 						case "api_req_started":
@@ -957,6 +958,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
 				case "api_req_deleted":
 					return false
 				case "api_req_retry_delayed":
+				case "api_req_rate_limit_wait":
 					const last1 = modifiedMessages.at(-1)
 					const last2 = modifiedMessages.at(-2)
 					if (last1?.ask === "resume_task" && last2 === message) {

+ 78 - 0
webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx

@@ -0,0 +1,78 @@
+import React from "react"
+
+import { render, screen } from "@/utils/test-utils"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext"
+import { ChatRowContent } from "../ChatRow"
+
+// Mock i18n
+vi.mock("react-i18next", () => ({
+	useTranslation: () => ({
+		t: (key: string) => {
+			const map: Record<string, string> = {
+				"chat:apiRequest.rateLimitWait": "Rate limiting",
+			}
+			return map[key] ?? key
+		},
+	}),
+	Trans: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
+	initReactI18next: { type: "3rdParty", init: () => {} },
+}))
+
+const queryClient = new QueryClient()
+
+function renderChatRow(message: any) {
+	return render(
+		<ExtensionStateContextProvider>
+			<QueryClientProvider client={queryClient}>
+				<ChatRowContent
+					message={message}
+					isExpanded={false}
+					isLast={false}
+					isStreaming={false}
+					onToggleExpand={() => {}}
+					onSuggestionClick={() => {}}
+					onBatchFileResponse={() => {}}
+					onFollowUpUnmount={() => {}}
+					isFollowUpAnswered={false}
+				/>
+			</QueryClientProvider>
+		</ExtensionStateContextProvider>,
+	)
+}
+
+describe("ChatRow - rate limit wait", () => {
+	it("renders a non-error progress row for api_req_rate_limit_wait", () => {
+		const message: any = {
+			type: "say",
+			say: "api_req_rate_limit_wait",
+			ts: Date.now(),
+			partial: true,
+			text: JSON.stringify({ seconds: 1 }),
+		}
+
+		renderChatRow(message)
+
+		expect(screen.getByText("Rate limiting")).toBeInTheDocument()
+		// Should show countdown, but should NOT show the error-details affordance.
+		expect(screen.getByText("1s")).toBeInTheDocument()
+		expect(screen.queryByText("Details")).toBeNull()
+	})
+
+	it("renders nothing when rate limit wait is complete", () => {
+		const message: any = {
+			type: "say",
+			say: "api_req_rate_limit_wait",
+			ts: Date.now(),
+			partial: false,
+			text: undefined,
+		}
+
+		const { container } = renderChatRow(message)
+
+		// The row should be hidden when rate limiting is complete
+		expect(screen.queryByText("Rate limiting")).toBeNull()
+		// Nothing should be rendered
+		expect(container.firstChild).toBeNull()
+	})
+})

+ 1 - 0
webview-ui/src/i18n/locales/ca/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "Sol·licitud API...",
 		"cancelled": "Sol·licitud API cancel·lada",
 		"streamingFailed": "Transmissió API ha fallat",
+		"rateLimitWait": "Limitació de taxa",
 		"errorTitle": "Error de proveïdor {{code}}",
 		"errorMessage": {
 			"docs": "Documentació",

+ 1 - 0
webview-ui/src/i18n/locales/de/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "API-Anfrage...",
 		"cancelled": "API-Anfrage abgebrochen",
 		"streamingFailed": "API-Streaming fehlgeschlagen",
+		"rateLimitWait": "Ratenbegrenzung",
 		"errorTitle": "Anbieter-Fehler {{code}}",
 		"errorMessage": {
 			"docs": "Dokumentation",

+ 1 - 0
webview-ui/src/i18n/locales/en/chat.json

@@ -144,6 +144,7 @@
 		"streaming": "API Request...",
 		"cancelled": "API Request Cancelled",
 		"streamingFailed": "API Streaming Failed",
+		"rateLimitWait": "Rate limiting",
 		"errorTitle": "Provider Error {{code}}",
 		"errorMessage": {
 			"docs": "Docs",

+ 1 - 0
webview-ui/src/i18n/locales/es/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "Solicitud API...",
 		"cancelled": "Solicitud API cancelada",
 		"streamingFailed": "Transmisión API falló",
+		"rateLimitWait": "Limitación de tasa",
 		"errorTitle": "Error del proveedor {{code}}",
 		"errorMessage": {
 			"docs": "Documentación",

+ 1 - 0
webview-ui/src/i18n/locales/fr/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "Requête API...",
 		"cancelled": "Requête API annulée",
 		"streamingFailed": "Échec du streaming API",
+		"rateLimitWait": "Limitation du débit",
 		"errorTitle": "Erreur du fournisseur {{code}}",
 		"errorMessage": {
 			"docs": "Documentation",

+ 1 - 0
webview-ui/src/i18n/locales/hi/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "API अनुरोध...",
 		"cancelled": "API अनुरोध रद्द किया गया",
 		"streamingFailed": "API स्ट्रीमिंग विफल हुई",
+		"rateLimitWait": "दर सीमा",
 		"errorTitle": "प्रदाता त्रुटि {{code}}",
 		"errorMessage": {
 			"docs": "डॉक्स",

+ 1 - 0
webview-ui/src/i18n/locales/id/chat.json

@@ -147,6 +147,7 @@
 		"streaming": "Permintaan API...",
 		"cancelled": "Permintaan API Dibatalkan",
 		"streamingFailed": "Streaming API Gagal",
+		"rateLimitWait": "Pembatasan rate",
 		"errorTitle": "Kesalahan Penyedia {{code}}",
 		"errorMessage": {
 			"docs": "Dokumentasi",

+ 1 - 0
webview-ui/src/i18n/locales/it/chat.json

@@ -141,6 +141,7 @@
 		"streaming": "Richiesta API...",
 		"cancelled": "Richiesta API annullata",
 		"streamingFailed": "Streaming API fallito",
+		"rateLimitWait": "Limitazione della frequenza",
 		"errorTitle": "Errore del fornitore {{code}}",
 		"errorMessage": {
 			"docs": "Documentazione",

+ 1 - 0
webview-ui/src/i18n/locales/ja/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "APIリクエスト...",
 		"cancelled": "APIリクエストキャンセル",
 		"streamingFailed": "APIストリーミング失敗",
+		"rateLimitWait": "レート制限中",
 		"errorTitle": "プロバイダーエラー {{code}}",
 		"errorMessage": {
 			"docs": "ドキュメント",

+ 1 - 0
webview-ui/src/i18n/locales/ko/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "API 요청...",
 		"cancelled": "API 요청 취소됨",
 		"streamingFailed": "API 스트리밍 실패",
+		"rateLimitWait": "속도 제한",
 		"errorTitle": "공급자 오류 {{code}}",
 		"errorMessage": {
 			"docs": "문서",

+ 1 - 0
webview-ui/src/i18n/locales/nl/chat.json

@@ -133,6 +133,7 @@
 		"streaming": "API-verzoek...",
 		"cancelled": "API-verzoek geannuleerd",
 		"streamingFailed": "API-streaming mislukt",
+		"rateLimitWait": "Snelheidsbeperking",
 		"errorTitle": "Fout van provider {{code}}",
 		"errorMessage": {
 			"docs": "Documentatie",

+ 1 - 0
webview-ui/src/i18n/locales/pl/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "Zapytanie API...",
 		"cancelled": "Zapytanie API anulowane",
 		"streamingFailed": "Strumieniowanie API nie powiodło się",
+		"rateLimitWait": "Ograniczenie szybkości",
 		"errorTitle": "Błąd dostawcy {{code}}",
 		"errorMessage": {
 			"docs": "Dokumentacja",

+ 1 - 0
webview-ui/src/i18n/locales/pt-BR/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "Requisição API...",
 		"cancelled": "Requisição API cancelada",
 		"streamingFailed": "Streaming API falhou",
+		"rateLimitWait": "Limitação de taxa",
 		"errorTitle": "Erro do provedor {{code}}",
 		"errorMessage": {
 			"docs": "Documentação",

+ 1 - 0
webview-ui/src/i18n/locales/ru/chat.json

@@ -133,6 +133,7 @@
 		"streaming": "API-запрос...",
 		"cancelled": "API-запрос отменен",
 		"streamingFailed": "Ошибка потокового API-запроса",
+		"rateLimitWait": "Ограничение частоты",
 		"errorTitle": "Ошибка провайдера {{code}}",
 		"errorMessage": {
 			"docs": "Документация",

+ 1 - 0
webview-ui/src/i18n/locales/tr/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "API İsteği...",
 		"cancelled": "API İsteği İptal Edildi",
 		"streamingFailed": "API Akışı Başarısız",
+		"rateLimitWait": "Hız sınırlaması",
 		"errorTitle": "Sağlayıcı Hatası {{code}}",
 		"errorMessage": {
 			"docs": "Belgeler",

+ 1 - 0
webview-ui/src/i18n/locales/vi/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "Yêu cầu API...",
 		"cancelled": "Yêu cầu API đã hủy",
 		"streamingFailed": "Streaming API thất bại",
+		"rateLimitWait": "Giới hạn tốc độ",
 		"errorTitle": "Lỗi nhà cung cấp {{code}}",
 		"errorMessage": {
 			"docs": "Tài liệu",

+ 1 - 0
webview-ui/src/i18n/locales/zh-CN/chat.json

@@ -138,6 +138,7 @@
 		"streaming": "API请求...",
 		"cancelled": "API请求已取消",
 		"streamingFailed": "API流式传输失败",
+		"rateLimitWait": "请求频率限制",
 		"errorTitle": "提供商错误 {{code}}",
 		"errorMessage": {
 			"docs": "文档",

+ 1 - 0
webview-ui/src/i18n/locales/zh-TW/chat.json

@@ -144,6 +144,7 @@
 		"streaming": "正在處理 API 請求...",
 		"cancelled": "API 請求已取消",
 		"streamingFailed": "API 串流處理失敗",
+		"rateLimitWait": "速率限制",
 		"errorTitle": "提供商錯誤 {{code}}",
 		"errorMessage": {
 			"docs": "文件",