Explorar o código

feat(chat): add SpendLimitError UI for SPEND_LIMIT_EXCEEDED (429) (#10207)

* feat(chat): add SpendLimitError UI for SPEND_LIMIT_EXCEEDED (429)

When the Cline backend returns a 429 with code SPEND_LIMIT_EXCEEDED (org
budget cap hit), the chat error flow now shows a dedicated SpendLimitError
component instead of falling through to the generic rate-limit message.

Changes:
- proto/cline/account.proto: add submitLimitIncreaseRequest RPC +
  SubmitLimitIncreaseResponse message
- src/services/error/ClineError.ts: add SpendLimit error type; detect
  SPEND_LIMIT_EXCEEDED before the generic rate-limit pattern check
- src/services/account/ClineAccountService.ts: add
  submitLimitIncreaseRequestRPC() calling POST /api/v1/users/me/budget/request
- src/core/controller/account/submitLimitIncreaseRequest.ts: new gRPC
  handler wired automatically by npm run protos
- webview-ui/src/components/chat/SpendLimitError.tsx: new card component
  mirroring CreditLimitError; shows spent/limit amounts, resets_at, org
  attribution, and a Request Increase button with 5-min localSto
When the Cline backend returns a 429 with code SPEND_LIMIT_EXCEEDED (org
budgetrors to
  SpendLbudget cap hit), the chat budget_period,limit_usd,spent_usd,resets_at}
- component instead of falling through to the generic rate-limnd Limit
  Reac
Changes:
- proto/cline/account.p

* Update webview-ui/src/components/chat/SpendLimitError.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* chore: shorten spend limit error message verbiage

* fix(storybook): align spend limit story messages with component output

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Roberto Langarica hai 2 días
pai
achega
10197b038d

+ 9 - 0
proto/cline/account.proto

@@ -53,6 +53,10 @@ service AccountService {
 
   // Signs out of OpenAI Codex and clears stored credentials
   rpc openAiCodexSignOut(EmptyRequest) returns (Empty);
+
+  // Submits a spend limit increase request to the user's org admin.
+  // Called when the user hits a SPEND_LIMIT_EXCEEDED (429) error and clicks "Request Increase".
+  rpc submitLimitIncreaseRequest(EmptyRequest) returns (SubmitLimitIncreaseResponse);
 }
 
 message AuthStateChangedRequest {
@@ -125,6 +129,11 @@ message UsageTransaction {
   string operation = 13;
 }
 
+// Response from a spend limit increase request submission
+message SubmitLimitIncreaseResponse {
+  bool success = 1;
+}
+
 message PaymentTransaction {
   string paid_at = 1;
   string creator_id = 2;

+ 28 - 0
src/core/controller/account/submitLimitIncreaseRequest.ts

@@ -0,0 +1,28 @@
+import { SubmitLimitIncreaseResponse } from "@shared/proto/cline/account"
+import type { EmptyRequest } from "@shared/proto/cline/common"
+import { Logger } from "@/shared/services/Logger"
+import type { Controller } from "../index"
+
+/**
+ * Submits a spend limit increase request to the user's org admin.
+ * Called when the user clicks "Request Increase" on the SpendLimitError component.
+ * @param controller The controller instance
+ * @param _request Empty request
+ * @returns SubmitLimitIncreaseResponse indicating success or failure
+ */
+export async function submitLimitIncreaseRequest(
+	controller: Controller,
+	_request: EmptyRequest,
+): Promise<SubmitLimitIncreaseResponse> {
+	try {
+		if (!controller.accountService) {
+			throw new Error("Account service not available")
+		}
+
+		await controller.accountService.submitLimitIncreaseRequestRPC()
+		return SubmitLimitIncreaseResponse.create({ success: true })
+	} catch (error) {
+		Logger.error(`Failed to submit limit increase request: ${error}`)
+		throw error
+	}
+}

+ 14 - 7
src/core/task/index.ts

@@ -2080,6 +2080,7 @@ export class Task {
 				}
 
 				const isAuthError = clineError.isErrorType(ClineErrorType.Auth)
+				const isSpendLimitError = clineError.isErrorType(ClineErrorType.SpendLimit)
 
 				// Check if this is a Cline provider insufficient credits error - don't auto-retry these
 				const isClineProviderInsufficientCredits = (() => {
@@ -2095,8 +2096,13 @@ export class Task {
 				})()
 
 				let response: ClineAskResponse
-				// Skip auto-retry for Cline provider insufficient credits or auth errors
-				if (!isClineProviderInsufficientCredits && !isAuthError && this.taskState.autoRetryAttempts < 3) {
+				// Skip auto-retry for Cline provider insufficient credits, auth errors, or spend limit errors
+				if (
+					!isClineProviderInsufficientCredits &&
+					!isAuthError &&
+					!isSpendLimitError &&
+					this.taskState.autoRetryAttempts < 3
+				) {
 					// Auto-retry enabled with max 3 attempts: automatically approve the retry
 					this.taskState.autoRetryAttempts++
 
@@ -2146,8 +2152,8 @@ export class Task {
 
 					await setTimeoutPromise(delay)
 				} else {
-					// Show error_retry with failed flag to indicate all retries exhausted (but not for insufficient credits)
-					if (!isClineProviderInsufficientCredits && !isAuthError) {
+					// Show error_retry with failed flag to indicate all retries exhausted (but not for insufficient credits or spend limit)
+					if (!isClineProviderInsufficientCredits && !isAuthError && !isSpendLimitError) {
 						await this.say(
 							"error_retry",
 							JSON.stringify({
@@ -3003,8 +3009,9 @@ export class Task {
 				if (!this.taskState.abandoned) {
 					const clineError = ErrorService.get().toClineError(error, this.api.getModel().id)
 					const errorMessage = clineError.serialize()
-					// Auto-retry for streaming failures (always enabled)
-					if (this.taskState.autoRetryAttempts < 3) {
+					const isStreamingSpendLimitError = clineError.isErrorType(ClineErrorType.SpendLimit)
+					// Auto-retry for streaming failures (skip for spend limit errors)
+					if (!isStreamingSpendLimitError && this.taskState.autoRetryAttempts < 3) {
 						this.taskState.autoRetryAttempts++
 
 						// Calculate exponential backoff for streaming failures: 2s, 4s, 8s
@@ -3030,7 +3037,7 @@ export class Task {
 								await this.controller.task.handleWebviewAskResponse("yesButtonClicked", "", [])
 							}
 						})
-					} else if (this.taskState.autoRetryAttempts >= 3) {
+					} else if (!isStreamingSpendLimitError && this.taskState.autoRetryAttempts >= 3) {
 						// Show error_retry with failed flag to indicate all retries exhausted
 						await this.say(
 							"error_retry",

+ 16 - 0
src/services/account/ClineAccountService.ts

@@ -234,6 +234,22 @@ export class ClineAccountService {
 		}
 	}
 
+	/**
+	 * Submits a spend limit increase request to the user's org admin.
+	 * Called when the user hits a SPEND_LIMIT_EXCEEDED (429) error and clicks "Request Increase".
+	 * @returns void — the backend records the request; errors are logged and swallowed
+	 */
+	async submitLimitIncreaseRequestRPC(): Promise<void> {
+		try {
+			await this.authenticatedRequest<void>("/api/v1/users/me/budget/request", {
+				method: "POST",
+			})
+		} catch (error) {
+			Logger.error("Failed to submit limit increase request (RPC):", error)
+			throw error
+		}
+	}
+
 	/**
 	 * Switches the active account to the specified organization or personal account.
 	 * @param organizationId - Optional organization ID to switch to. If not provided, it will switch to the personal account.

+ 7 - 0
src/services/error/ClineError.ts

@@ -6,6 +6,7 @@ export enum ClineErrorType {
 	Network = "network",
 	RateLimit = "rateLimit",
 	Balance = "balance",
+	SpendLimit = "spendLimit",
 }
 
 interface ErrorDetails {
@@ -144,6 +145,12 @@ export class ClineError extends Error {
 			return ClineErrorType.Balance
 		}
 
+		// Check spend limit exceeded (org-enforced budget cap, 429 SPEND_LIMIT_EXCEEDED)
+		// Must be checked before the generic rate-limit check since both use 429
+		if (code === "SPEND_LIMIT_EXCEEDED" || details?.code === "SPEND_LIMIT_EXCEEDED") {
+			return ClineErrorType.SpendLimit
+		}
+
 		// Check auth errors
 		const isAuthStatus = status !== undefined && status > 400 && status < 429
 		if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {

+ 2 - 2
src/test/e2e/fixtures/server/api.ts

@@ -12,12 +12,12 @@ export const E2E_REGISTERED_MOCK_ENDPOINTS = {
 			"/users/{userId}/usages",
 			"/users/{userId}/payments",
 		],
-		POST: ["/chat/completions", "/auth/token"],
+		POST: ["/chat/completions", "/auth/token", "/users/me/budget/request"],
 		PUT: ["/users/active-account"],
 	},
 	"/.test": {
 		GET: [],
-		POST: ["/auth", "/setUserBalance", "/setUserHasOrganization", "/setOrgBalance"],
+		POST: ["/auth", "/setUserBalance", "/setUserHasOrganization", "/setOrgBalance", "/setSpendLimitExceeded"],
 		PUT: [],
 	},
 	"/health": {

+ 47 - 0
src/test/e2e/fixtures/server/index.ts

@@ -24,6 +24,7 @@ export class ClineApiServerMock {
 	private userBalance = 100.5 // Default sufficient balance
 	private orgBalance = 500.0
 	private userHasOrganization = false
+	private spendLimitExceeded = false
 	public generationCounter = 0
 
 	public readonly API_USER = new ClineDataMock("personal")
@@ -49,6 +50,16 @@ export class ClineApiServerMock {
 		this.orgBalance = balance
 	}
 
+	/**
+	 * Puts the mock server into "spend limit exceeded" mode.
+	 * While true, POST /api/v1/chat/completions returns 429 SPEND_LIMIT_EXCEEDED
+	 * instead of a normal streaming response.
+	 * Toggle off to resume normal behaviour.
+	 */
+	public setSpendLimitExceeded(exceeded: boolean) {
+		this.spendLimitExceeded = exceeded
+	}
+
 	public setCurrentUser(user: UserResponse | null) {
 		this.API_USER.setCurrentUser(user)
 		this.currentUser = user
@@ -369,8 +380,35 @@ export class ClineApiServerMock {
 						})
 					}
 
+					// Budget limit increase request endpoint
+					if (endpoint === "/users/me/budget/request" && method === "POST") {
+						log("Spend limit increase request received — recording and notifying admin")
+						res.writeHead(204)
+						res.end()
+						return
+					}
+
 					// Chat completions endpoint
 					if (endpoint === "/chat/completions" && method === "POST") {
+						// Spend limit check takes priority — org-enforced budget cap (429)
+						if (controller.spendLimitExceeded) {
+							log("Returning SPEND_LIMIT_EXCEEDED (429)")
+							return sendJson(
+								{
+									error: {
+										code: "SPEND_LIMIT_EXCEEDED",
+										limit_scope: "user",
+										budget_period: "daily",
+										limit_usd: 20.0,
+										spent_usd: 20.5,
+										resets_at: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),
+										message: "Your daily spend limit of $20.00 has been reached.",
+									},
+								},
+								429,
+							)
+						}
+
 						if (!controller.userHasOrganization && controller.userBalance <= 0) {
 							return sendApiError(
 								JSON.stringify({
@@ -539,6 +577,15 @@ export class ClineApiServerMock {
 						res.end()
 						return
 					}
+
+					if (endpoint === "/setSpendLimitExceeded" && method === "POST") {
+						const body = await readBody()
+						const { exceeded } = JSON.parse(body)
+						controller.setSpendLimitExceeded(!!exceeded)
+						res.writeHead(200)
+						res.end()
+						return
+					}
 				}
 
 				// If we get here, the route was matched but not handled

+ 6 - 2
webview-ui/src/components/chat/ErrorBlockTitle.tsx

@@ -22,7 +22,7 @@ export const ErrorBlockTitle = ({
 }: ErrorBlockTitleProps): [React.ReactElement, React.ReactElement] => {
 	const getIconSpan = (iconName: string, colorClass: string) => (
 		<div className="w-4 h-4 flex items-center justify-center">
-			<span className={`codicon codicon-${iconName} text-base -mb-0.5 ${colorClass}`}></span>
+			<span className={`codicon codicon-${iconName} text-base -mb-0.5 ${colorClass}`} />
 		</div>
 	)
 
@@ -58,7 +58,11 @@ export const ErrorBlockTitle = ({
 		} else if (apiRequestFailedMessage) {
 			// Handle failed request
 			const clineError = ClineError.parse(apiRequestFailedMessage)
-			const titleText = clineError?.isErrorType(ClineErrorType.Balance) ? "Credit Limit Reached" : "API Request Failed"
+			const titleText = clineError?.isErrorType(ClineErrorType.Balance)
+				? "Credit Limit Reached"
+				: clineError?.isErrorType(ClineErrorType.SpendLimit)
+					? "Spend Limit Reached"
+					: "API Request Failed"
 			details.title = titleText
 			details.classNames.push("font-bold text-(--vscode-errorForeground)")
 		} else if (retryStatus) {

+ 61 - 0
webview-ui/src/components/chat/ErrorRow.stories.tsx

@@ -153,6 +153,67 @@ export const ClineRateLimitError: Story = {
 	},
 }
 
+export const ClineSpendLimitDaily: Story = {
+	args: {
+		message: createMockMessage(),
+		errorType: "error",
+		apiRequestFailedMessage: JSON.stringify({
+			message: "$20.00 daily limit has been reached.",
+			status: 429,
+			code: "SPEND_LIMIT_EXCEEDED",
+			providerId: "cline",
+			details: {
+				code: "SPEND_LIMIT_EXCEEDED",
+				limit_scope: "user",
+				budget_period: "daily",
+				limit_usd: 20.0,
+				spent_usd: 20.5,
+				resets_at: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(),
+				message: "$20.00 daily limit has been reached.",
+			},
+		}),
+	},
+}
+
+export const ClineSpendLimitMonthly: Story = {
+	args: {
+		message: createMockMessage(),
+		errorType: "error",
+		apiRequestFailedMessage: JSON.stringify({
+			message: "$100.00 monthly limit has been reached.",
+			status: 429,
+			code: "SPEND_LIMIT_EXCEEDED",
+			providerId: "cline",
+			details: {
+				code: "SPEND_LIMIT_EXCEEDED",
+				limit_scope: "user",
+				budget_period: "monthly",
+				limit_usd: 100.0,
+				spent_usd: 103.22,
+				resets_at: null,
+				message: "$100.00 monthly limit has been reached.",
+			},
+		}),
+	},
+}
+
+export const ClineSpendLimitMinimal: Story = {
+	args: {
+		message: createMockMessage(),
+		errorType: "error",
+		apiRequestFailedMessage: JSON.stringify({
+			message: "Spend limit reached.",
+			status: 429,
+			code: "SPEND_LIMIT_EXCEEDED",
+			providerId: "cline",
+			details: {
+				code: "SPEND_LIMIT_EXCEEDED",
+				message: "Spend limit reached.",
+			},
+		}),
+	},
+}
+
 // Authentication-related errors with configurable scenarios
 export const AuthenticationErrors: Story = {
 	args: {

+ 14 - 0
webview-ui/src/components/chat/ErrorRow.tsx

@@ -1,6 +1,7 @@
 import { ClineMessage } from "@shared/ExtensionMessage"
 import { memo } from "react"
 import CreditLimitError from "@/components/chat/CreditLimitError"
+import SpendLimitError from "@/components/chat/SpendLimitError"
 import { Button } from "@/components/ui/button"
 import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext"
 import { ClineError, ClineErrorType } from "../../../../src/services/error/ClineError"
@@ -47,6 +48,19 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
 						)
 					}
 
+					if (clineError?.isErrorType(ClineErrorType.SpendLimit)) {
+						const d = clineError._error?.details
+						return (
+							<SpendLimitError
+								budgetPeriod={d?.budget_period}
+								limitUsd={d?.limit_usd}
+								message={d?.message || errorMessage}
+								resetsAt={d?.resets_at}
+								spentUsd={d?.spent_usd}
+							/>
+						)
+					}
+
 					if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
 						return (
 							<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">

+ 145 - 0
webview-ui/src/components/chat/SpendLimitError.tsx

@@ -0,0 +1,145 @@
+import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
+import React, { useEffect, useState } from "react"
+import { AccountServiceClient } from "@/services/grpc-client"
+
+const COOLDOWN_MS = 5 * 60 * 1000 // 5 minutes
+const COOLDOWN_KEY = "cline:spendLimitRequestCooldown"
+
+type RequestButtonState = "idle" | "sending" | "sent"
+
+function formatResetsAt(resetsAt?: string): string | null {
+	if (!resetsAt) return null
+	try {
+		const date = new Date(resetsAt)
+		if (isNaN(date.getTime())) return null
+		return date.toLocaleDateString(undefined, {
+			month: "short",
+			day: "numeric",
+			hour: "2-digit",
+			minute: "2-digit",
+		})
+	} catch {
+		return null
+	}
+}
+
+interface SpendLimitErrorProps {
+	/** Human-readable error message from the backend */
+	message: string
+	/** Which period the limit applies to: "daily" | "monthly" */
+	budgetPeriod?: string
+	/** The configured spend limit in USD */
+	limitUsd?: number
+	/** How much the user has spent in USD this period */
+	spentUsd?: number
+	/** ISO 8601 timestamp of when the limit resets (may be null for monthly) */
+	resetsAt?: string
+}
+
+const SpendLimitError: React.FC<SpendLimitErrorProps> = ({ message, budgetPeriod, limitUsd, spentUsd, resetsAt }) => {
+	const displayMessage =
+		limitUsd != null && budgetPeriod ? `$${limitUsd.toFixed(2)} ${budgetPeriod} limit has been reached.` : message
+
+	const [buttonState, setButtonState] = useState<RequestButtonState>(() => {
+		try {
+			const ts = localStorage.getItem(COOLDOWN_KEY)
+			if (ts && Date.now() - Number(ts) < COOLDOWN_MS) return "sent"
+		} catch {
+			// localStorage may not be available in some environments
+		}
+		return "idle"
+	})
+
+	// Reset button to idle once cooldown expires
+	useEffect(() => {
+		if (buttonState !== "sent") return
+		try {
+			const ts = localStorage.getItem(COOLDOWN_KEY)
+			if (!ts) {
+				setButtonState("idle")
+				return
+			}
+			const remaining = COOLDOWN_MS - (Date.now() - Number(ts))
+			if (remaining <= 0) {
+				setButtonState("idle")
+				return
+			}
+			const timer = setTimeout(() => setButtonState("idle"), remaining)
+			return () => clearTimeout(timer)
+		} catch {
+			// Ignore localStorage errors
+		}
+	}, [buttonState])
+
+	const handleRequestIncrease = async () => {
+		setButtonState("sending")
+		try {
+			await AccountServiceClient.submitLimitIncreaseRequest({})
+			localStorage.setItem(COOLDOWN_KEY, String(Date.now()))
+			setButtonState("sent")
+		} catch (error) {
+			console.error("Failed to submit limit increase request:", error)
+			setButtonState("idle")
+		}
+	}
+
+	const periodLabel = budgetPeriod ? budgetPeriod.charAt(0).toUpperCase() + budgetPeriod.slice(1) : ""
+	const resetsAtFormatted = formatResetsAt(resetsAt)
+
+	return (
+		<div className="border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)" style={{ padding: "10px 12px" }}>
+			<div className="mb-3">
+				<div className="text-error mb-2" style={{ fontSize: "calc(var(--vscode-font-size) + 2px)" }}>
+					{displayMessage}
+				</div>
+
+				<div className="mb-3">
+					{spentUsd != null && limitUsd != null && (
+						<div className="text-foreground" style={{ fontSize: "var(--vscode-font-size)", lineHeight: 1.3 }}>
+							{periodLabel ? `${periodLabel} usage` : "Usage"}:{" "}
+							<span className="font-bold">
+								${spentUsd.toFixed(2)} / ${limitUsd.toFixed(2)}
+							</span>
+						</div>
+					)}
+
+					{resetsAtFormatted && (
+						<div className="text-foreground" style={{ fontSize: "var(--vscode-font-size)", lineHeight: 1.3 }}>
+							Resets: <span className="font-bold">{resetsAtFormatted}</span>
+						</div>
+					)}
+
+					<div className="text-(--vscode-descriptionForeground) mt-2 text-xs inline-flex items-center">
+						<span className="codicon codicon-organization mr-1" />
+						Limits set by your organization.
+					</div>
+				</div>
+			</div>
+
+			<VSCodeButton
+				appearance="primary"
+				className="w-full"
+				disabled={buttonState !== "idle"}
+				onClick={handleRequestIncrease}>
+				{buttonState === "sending" ? (
+					<>
+						<span className="codicon codicon-loading codicon-modifier-spin mr-1.5" />
+						Sending…
+					</>
+				) : buttonState === "sent" ? (
+					<>
+						<span className="codicon codicon-check mr-1.5" />
+						Request Sent
+					</>
+				) : (
+					<>
+						<span className="codicon codicon-arrow-up mr-1.5" />
+						Request Increase
+					</>
+				)}
+			</VSCodeButton>
+		</div>
+	)
+}
+
+export default SpendLimitError