diff --git a/packages/evals/src/cli/runTask.ts b/packages/evals/src/cli/runTask.ts index a6ae6c03059..d93aa5bc37d 100644 --- a/packages/evals/src/cli/runTask.ts +++ b/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", ] diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 82f58f29f28..109cd842bac 100644 --- a/packages/types/src/message.ts +++ b/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", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1bdbb55ba94..743385a9f85 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2343,6 +2343,17 @@ export class Task extends EventEmitter 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({ @@ -2550,7 +2561,7 @@ export class Task extends EventEmitter 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[] = [] @@ -3652,7 +3663,44 @@ export class Task extends EventEmitter 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 { + 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 { @@ -3689,29 +3737,17 @@ export class Task extends EventEmitter 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() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 6ae4b1ecb7a..c3a88b3e298 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/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 () => { diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index fee93f68a6f..ef2231b26d1 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -298,6 +298,8 @@ export const ChatRowContent = ({ style={{ color: successColor, marginBottom: "-1.5px" }}>, {t("chat:taskCompleted")}, ] + 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 ? ( + ) : ( + 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 ? ( +
+
+ + {t("chat:apiRequest.rateLimitWait")} +
+ {waitSeconds}s +
+ ) : null + } case "api_req_finished": return null // we should never see this message type case "text": diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index a002abf8cfb..b358abaaa84 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -400,6 +400,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ + useTranslation: () => ({ + t: (key: string) => { + const map: Record = { + "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( + + + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + /> + + , + ) +} + +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() + }) +}) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 5a049ad6955..efed3677aad 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/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ó", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index bb0f8e28460..2f3a6eac2e8 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 2d71f43b2bf..a4d1ffef9fb 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index d559ceab32e..aa59cdfe016 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 7593f9fda39..d1594116c0e 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 0da3ac4f7e4..cbeafe9b5c8 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -138,6 +138,7 @@ "streaming": "API अनुरोध...", "cancelled": "API अनुरोध रद्द किया गया", "streamingFailed": "API स्ट्रीमिंग विफल हुई", + "rateLimitWait": "दर सीमा", "errorTitle": "प्रदाता त्रुटि {{code}}", "errorMessage": { "docs": "डॉक्स", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 2e3497d789d..4d1c0fcceb1 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index bcdd4699f5f..364a2405d2e 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 644743f950c..8ce5f80b027 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -138,6 +138,7 @@ "streaming": "APIリクエスト...", "cancelled": "APIリクエストキャンセル", "streamingFailed": "APIストリーミング失敗", + "rateLimitWait": "レート制限中", "errorTitle": "プロバイダーエラー {{code}}", "errorMessage": { "docs": "ドキュメント", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index dcc8d548707..0cf4e073507 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -138,6 +138,7 @@ "streaming": "API 요청...", "cancelled": "API 요청 취소됨", "streamingFailed": "API 스트리밍 실패", + "rateLimitWait": "속도 제한", "errorTitle": "공급자 오류 {{code}}", "errorMessage": { "docs": "문서", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 4b9f1318963..5c810e5add5 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 6a83fbfdf2c..96b4411f6be 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 48f42c6d502..05031e84ad2 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 23e0e24e6a9..af2c5ae83c4 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -133,6 +133,7 @@ "streaming": "API-запрос...", "cancelled": "API-запрос отменен", "streamingFailed": "Ошибка потокового API-запроса", + "rateLimitWait": "Ограничение частоты", "errorTitle": "Ошибка провайдера {{code}}", "errorMessage": { "docs": "Документация", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index a37f21851b6..94a6791b27b 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 5848e85aff2..a8380f4c3b5 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/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", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index c7350a3dd7a..ab834bc1d2e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -138,6 +138,7 @@ "streaming": "API请求...", "cancelled": "API请求已取消", "streamingFailed": "API流式传输失败", + "rateLimitWait": "请求频率限制", "errorTitle": "提供商错误 {{code}}", "errorMessage": { "docs": "文档", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 7a10aa2fdac..9cf54ee53f6 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -144,6 +144,7 @@ "streaming": "正在處理 API 請求...", "cancelled": "API 請求已取消", "streamingFailed": "API 串流處理失敗", + "rateLimitWait": "速率限制", "errorTitle": "提供商錯誤 {{code}}", "errorMessage": { "docs": "文件",