Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/evals/src/cli/runTask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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",
Expand Down
80 changes: 58 additions & 22 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2343,6 +2343,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({
Expand Down Expand Up @@ -2550,7 +2561,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[] = []
Expand Down Expand Up @@ -3652,7 +3663,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 {
Expand Down Expand Up @@ -3689,29 +3737,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()
Expand Down
14 changes: 14 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]() {
Expand All @@ -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 () => {
Expand Down
47 changes: 45 additions & 2 deletions webview-ui/src/components/chat/ChatRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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" ? (
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/ca/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/de/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/es/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/fr/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/hi/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/id/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/it/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading