Compare commits

...
Author SHA1 Message Date
Roberto Langarica 8588750283 feat(cost-control): enforce third-party API spend limits [PF-672]
Extends existing Cline-provider spend-limit enforcement to non-Cline providers (Anthropic, OpenAI, Gemini, etc.) by checking cached budget status before each API request. Reuses the existing SpendLimitError UI with zero webview changes.

- New ThirdPartySpendLimitService with configurable TTL (default 5m, CLINE_SPEND_LIMIT_TTL_MS env or setTtlMs())
- New fetchOverbudgetStatusRPC on ClineAccountService (403/404 = feature off)
- Enforcement hook in Task.attemptApiRequest (skips 'cline' provider)
- Cache warm-up on auth restore (covers login + org switch)
- New shared types: OverbudgetStatus, EffectiveLimits, BudgetUserCurrentPeriod, LimitSource
- Mock server script for manual testing
- 12 unit tests (6 new TTL-specific cases)

Depends on backend PR #2413.
2026-04-19 19:10:53 -07:00
Roberto Langarica 31156470d4 fix(storybook): align spend limit story messages with component output 2026-04-09 13:06:19 -07:00
Roberto Langarica ec7a296eed chore: shorten spend limit error message verbiage 2026-04-09 12:34:52 -07:00
Roberto Langaricaandgreptile-apps[bot] 375bb2a93a Update webview-ui/src/components/chat/SpendLimitError.tsx
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-04-09 11:41:44 -07:00
Roberto Langarica 812d75d0d2 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
2026-04-09 07:33:09 -07:00
17 changed files with 1000 additions and 11 deletions
+9
View File
@@ -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;
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env node
/**
* mock-spend-limit-server.mjs
*
* Lightweight proxy for hands-on testing of the SpendLimitError UI.
*
* - POST /api/v1/chat/completions → 429 SPEND_LIMIT_EXCEEDED
* - POST /api/v1/users/me/budget/request → 204 OK (simulates "Request Increase" success)
* - Everything else → proxied to REAL_BACKEND
*
* Usage:
* node scripts/mock-spend-limit-server.mjs
*
* Then point the extension at http://localhost:7777 by adding to .vscode/launch.json:
* "env": { "CLINE_API_BASE_URL": "http://localhost:7777" }
*
* See docs/testing/spend-limit-error-hands-on.md for the full guide.
*/
import { createServer } from "node:http"
import { request as httpsRequest } from "node:https"
const PORT = 7777
const REAL_BACKEND = "https://api.cline.bot" // swap for your local backend if needed
// ── Tune these to change what the card shows ─────────────────────────────────
const SPEND_LIMIT_BODY = JSON.stringify({
error: {
code: "SPEND_LIMIT_EXCEEDED",
limit_scope: "user",
budget_period: "daily", // "daily" | "monthly"
limit_usd: 20.0,
spent_usd: 20.5,
resets_at: new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString(), // 8h from now
message: "Your daily spend limit of $20.00 has been reached.",
},
})
// ─────────────────────────────────────────────────────────────────────────────
function proxyToReal(req, res, body) {
const url = new URL(req.url, REAL_BACKEND)
const options = {
hostname: url.hostname,
port: 443,
path: url.pathname + url.search,
method: req.method,
headers: { ...req.headers, host: url.hostname },
}
const proxy = httpsRequest(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers)
proxyRes.pipe(res)
})
proxy.on("error", (e) => {
console.error("[proxy] Error:", e.message)
res.writeHead(502)
res.end("Bad gateway")
})
if (body?.length) proxy.write(body)
proxy.end()
}
createServer((req, res) => {
const chunks = []
req.on("data", (c) => chunks.push(c))
req.on("end", () => {
const body = Buffer.concat(chunks)
if (req.url?.includes("/chat/completions")) {
// ── Intercept: return SPEND_LIMIT_EXCEEDED ───────────────
console.log(`\x1b[31m[mock]\x1b[0m 429 SPEND_LIMIT_EXCEEDED ${req.method} ${req.url}`)
res.writeHead(429, { "Content-Type": "application/json" })
res.end(SPEND_LIMIT_BODY)
} else if (req.url?.includes("/budget/request") && req.method === "POST") {
// ── Intercept: simulate successful limit-increase request ─
console.log(`\x1b[32m[mock]\x1b[0m 204 OK POST ${req.url}`)
res.writeHead(204)
res.end()
} else {
// ── Proxy everything else to the real backend ─────────────
console.log(`\x1b[90m[proxy]\x1b[0m ${req.method} ${req.url}`)
proxyToReal(req, res, body)
}
})
}).listen(PORT, () => {
console.log(`
\x1b[1mMock spend-limit server\x1b[0m → http://localhost:${PORT}
\x1b[31m✗\x1b[0m POST /api/v1/chat/completions 429 SPEND_LIMIT_EXCEEDED
\x1b[32m✓\x1b[0m POST /api/v1/.../budget/request 204 OK
\x1b[90m↗\x1b[0m everything else proxy → ${REAL_BACKEND}
Point the extension at this server:
.vscode/launch.json → "env": { "CLINE_API_BASE_URL": "http://localhost:${PORT}" }
See docs/testing/spend-limit-error-hands-on.md for the full walkthrough.
`)
})
@@ -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
}
}
+63 -7
View File
@@ -91,6 +91,7 @@ import {
StandaloneTerminalManager,
} from "@/integrations/terminal"
import { ClineError, ClineErrorType, ErrorService } from "@/services/error"
import { ThirdPartySpendLimitService } from "@/services/spend-limit/ThirdPartySpendLimitService"
import { telemetryService } from "@/services/telemetry"
import { ClineClient } from "@/shared/cline"
import {
@@ -1742,6 +1743,51 @@ export class Task {
return { model, providerId, customPrompt, mode }
}
/**
* Enforces third-party (non-Cline) spend limits locally before dispatching
* an API request. Throws a ClineError shaped like the Cline-provider 429
* so the existing SpendLimitError UI handles it with no webview changes.
*/
private async checkThirdPartySpendLimit(providerId: string | undefined): Promise<void> {
// Cline provider has server-side enforcement.
if (providerId === "cline") {
return
}
const svc = ThirdPartySpendLimitService.getInstance()
await svc.fetchIfNeeded()
const status = svc.getStatus()
if (!status?.overbudget) {
return
}
// Daily limit takes precedence over monthly when both are set, since
// it will reset sooner.
const hitDaily = status.limits.dailyLimitUsd != null
const budgetPeriod: "daily" | "monthly" = hitDaily ? "daily" : "monthly"
const limitUsd = hitDaily ? status.limits.dailyLimitUsd : status.limits.monthlyLimitUsd
const spentUsd = hitDaily ? status.usage.dailySpendUsd : status.usage.monthlySpendUsd
const resetsAt = hitDaily ? status.usage.dayResetsAt : status.usage.monthResetsAt
const formattedLimit = typeof limitUsd === "number" ? `$${limitUsd.toFixed(2)} ` : ""
const message = `Your organization's ${formattedLimit}${budgetPeriod} spend limit has been reached.`
throw ClineError.transform({
status: 429,
code: "SPEND_LIMIT_EXCEEDED",
message,
details: {
code: "SPEND_LIMIT_EXCEEDED",
budget_period: budgetPeriod,
limit_usd: limitUsd,
spent_usd: spentUsd,
resets_at: resetsAt,
message,
},
})
}
private async writePromptMetadataArtifacts(params: { systemPrompt: string; providerInfo: ApiProviderInfo }): Promise<void> {
const enabledFlag = process.env.CLINE_WRITE_PROMPT_ARTIFACTS?.toLowerCase()
const enabled = enabledFlag === "1" || enabledFlag === "true" || enabledFlag === "yes"
@@ -1868,6 +1914,9 @@ export class Task {
})
const providerInfo = this.getCurrentProviderInfo()
// Block overbudget third-party requests before they reach the provider.
await this.checkThirdPartySpendLimit(providerInfo.providerId)
const host = await HostProvider.env.getHostVersion({})
const ide = host?.platform || "Unknown"
const isCliEnvironment = host.clineType === ClineClient.Cli
@@ -2080,6 +2129,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 +2145,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 +2201,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 +3058,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 +3086,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",
@@ -3,6 +3,7 @@ import type {
FeaturebaseTokenResponse,
OrganizationBalanceResponse,
OrganizationUsageTransaction,
OverbudgetStatus,
PaymentTransaction,
UsageTransaction,
UserResponse,
@@ -234,6 +235,43 @@ export class ClineAccountService {
}
}
/**
* RPC variant that fetches the overbudget status for the given org.
* Returns undefined when the feature is not enabled (403/404) or on failure
* so that spend-control checks never block tasks on transient errors.
*/
async fetchOverbudgetStatusRPC(organizationId: string): Promise<OverbudgetStatus | undefined> {
try {
return await this.authenticatedRequest<OverbudgetStatus>(`/api/v1/organizations/${organizationId}/budget/overbudget`)
} catch (error) {
// 403/404 = non-enterprise org, expected for most users
if (axios.isAxiosError(error)) {
const status = error.response?.status
if (status === 403 || status === 404) {
return undefined
}
}
Logger.error("Failed to fetch overbudget status (RPC):", error)
return undefined
}
}
/**
* 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.
+8
View File
@@ -316,6 +316,14 @@ export class AuthService {
if (this._clineAuthInfo) {
this._authenticated = true
await this.sendAuthStatusUpdate()
// Warm the third-party spend-limit cache so the status is ready
// before the user's first task. Fire-and-forget; failures are
// fully handled inside the service (logged, not thrown).
// Dynamic import to avoid a hard dependency cycle with task layer.
import("../spend-limit/ThirdPartySpendLimitService")
.then(({ ThirdPartySpendLimitService }) => ThirdPartySpendLimitService.getInstance().fetchIfNeeded())
.catch((err) => Logger.debug(`[SpendControl] Cache-warm on login failed: ${err}`))
} else {
Logger.warn("No user found after restoring auth token")
this._authenticated = false
+7
View File
@@ -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) {
@@ -0,0 +1,177 @@
import type { OverbudgetStatus } from "@shared/ClineAccount"
import { Logger } from "@/shared/services/Logger"
import { ClineAccountService } from "../account/ClineAccountService"
import { AuthService } from "../auth/AuthService"
/**
* Default TTL (5 minutes) before a cached status is considered stale and
* refreshed on the next `fetchIfNeeded()` call.
*/
export const DEFAULT_SPEND_LIMIT_TTL_MS = 5 * 60 * 1000
/**
* Read a TTL override from the environment. Supports either a raw ms value
* (e.g. "60000") or a humanised shorthand (e.g. "5m", "30s", "1m").
* Returns `undefined` when the override is missing/invalid so the caller can
* fall back to the default.
*/
function resolveTtlFromEnv(): number | undefined {
const raw = process.env.CLINE_SPEND_LIMIT_TTL_MS?.trim()
if (!raw) {
return undefined
}
const shorthand = raw.match(/^(\d+)\s*(ms|s|m)?$/i)
if (!shorthand) {
return undefined
}
const value = Number(shorthand[1])
if (!Number.isFinite(value) || value < 0) {
return undefined
}
const unit = (shorthand[2] ?? "ms").toLowerCase()
switch (unit) {
case "s":
return value * 1000
case "m":
return value * 60 * 1000
default:
return value
}
}
/**
* Caches the active org's third-party spend-limit status so Task can check it
* before dispatching non-Cline provider requests (Anthropic, OpenAI, etc.).
* The Cline provider is enforced server-side and is not checked here.
*
* The cache is a single slot keyed by the active org ID with a configurable
* TTL. Switching orgs always overwrites the slot; within the same org the
* status is refreshed on the next call after the TTL elapses.
*/
export class ThirdPartySpendLimitService {
private static instance: ThirdPartySpendLimitService
private cachedStatus: OverbudgetStatus | null = null
private cachedOrgId: string | null = null
private cachedAt = 0
private fetchPromise: Promise<void> | null = null
private ttlMs: number = resolveTtlFromEnv() ?? DEFAULT_SPEND_LIMIT_TTL_MS
// Injection seam for tests; defaults to real wall clock.
private now: () => number = () => Date.now()
private constructor() {}
public static getInstance(): ThirdPartySpendLimitService {
if (!ThirdPartySpendLimitService.instance) {
ThirdPartySpendLimitService.instance = new ThirdPartySpendLimitService()
}
return ThirdPartySpendLimitService.instance
}
/**
* Dynamically set the TTL (in ms). Useful for experimenting with shorter
* windows (e.g. 1 minute) without redeploying. Setting 0 disables caching
* entirely and forces every call to refetch.
*/
setTtlMs(ttlMs: number): void {
if (!Number.isFinite(ttlMs) || ttlMs < 0) {
throw new Error(`Invalid TTL: ${ttlMs}`)
}
this.ttlMs = ttlMs
}
/** Current TTL in ms. Exposed for diagnostics + tests. */
getTtlMs(): number {
return this.ttlMs
}
/**
* Ensures the cache reflects the currently active org. Safe to call from
* hot paths; a no-op while the cached entry is fresh. Never throws
* failures resolve to a null cache so they cannot block task execution.
*/
async fetchIfNeeded(): Promise<void> {
const activeOrgId = this.getActiveOrgId()
if (!activeOrgId) {
this.cachedStatus = null
this.cachedOrgId = null
this.cachedAt = 0
return
}
const cacheHit = this.cachedOrgId === activeOrgId && !this.isStale()
if (cacheHit) {
return
}
if (this.fetchPromise) {
await this.fetchPromise
if (this.cachedOrgId === activeOrgId && !this.isStale()) {
return
}
}
this.fetchPromise = this.doFetch(activeOrgId).finally(() => {
this.fetchPromise = null
})
return this.fetchPromise
}
private isStale(): boolean {
if (this.ttlMs === 0) {
return true
}
return this.now() - this.cachedAt >= this.ttlMs
}
private async doFetch(organizationId: string): Promise<void> {
try {
const status = await ClineAccountService.getInstance().fetchOverbudgetStatusRPC(organizationId)
this.cachedStatus = status ?? null
this.cachedOrgId = organizationId
this.cachedAt = this.now()
} catch (err) {
// Double-guard: fetchOverbudgetStatusRPC already swallows errors, but
// callers rely on this method never throwing.
Logger.error("Unexpected error fetching overbudget status:", err)
this.cachedStatus = null
this.cachedOrgId = organizationId
this.cachedAt = this.now()
}
}
/** Current cached status, or null if unavailable / feature not enabled. */
getStatus(): OverbudgetStatus | null {
return this.cachedStatus
}
/** Quick check for blocking decisions. */
isOverbudget(): boolean {
return this.cachedStatus?.overbudget === true
}
/** Clears the cache. Used on logout, active-org change, and in tests. */
invalidate(): void {
this.cachedStatus = null
this.cachedOrgId = null
this.cachedAt = 0
this.fetchPromise = null
}
/**
* Test-only seam: override the clock used to evaluate freshness.
* Not exported from the public surface beyond tests.
*/
_setClockForTest(now: () => number): void {
this.now = now
}
private getActiveOrgId(): string | null {
try {
return AuthService.getInstance().getActiveOrganizationId()
} catch {
return null
}
}
}
+44
View File
@@ -85,3 +85,47 @@ export interface OrganizationUsageTransaction {
// Used in cline.ts provider and in webview-ui/src/components/chat/ChatRow.tsx to display the login button
export const CLINE_ACCOUNT_AUTH_ERROR_MESSAGE = "Unauthorized: Please sign in to Cline before trying again."
// ---------------------------------------------------------------------------
// Spend control (third-party API spend limits)
// ---------------------------------------------------------------------------
/**
* Source of the effective limits for a user.
* - "none": no limits apply
* - "org_default": org-wide defaults apply to this user
* - "user_override": a per-user override has been set
*/
export type LimitSource = "none" | "org_default" | "user_override"
/**
* Effective budget limits for a user within an organization.
* All USD amounts. `null` means the limit is not set.
*/
export interface EffectiveLimits {
monthlyLimitUsd: number | null
dailyLimitUsd: number | null
orgMonthlyUsd: number | null
source: LimitSource
}
/**
* A user's current-period spend (for the active org).
* ISO-8601 timestamps for reset times.
*/
export interface BudgetUserCurrentPeriod {
monthlySpendUsd: number
dailySpendUsd: number
monthResetsAt?: string
dayResetsAt?: string
}
/**
* Payload returned by the backend overbudget check endpoint.
* See: GET /api/v1/organizations/{orgId}/budget/overbudget
*/
export interface OverbudgetStatus {
overbudget: boolean
limits: EffectiveLimits
usage: BudgetUserCurrentPeriod
}
+2 -2
View File
@@ -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
View File
@@ -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
@@ -1,4 +1,5 @@
import * as assert from "assert"
import axios from "axios"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
@@ -49,3 +50,73 @@ describe("ClineAccountService.fetchFeaturebaseToken", () => {
assert.strictEqual(result, undefined)
})
})
describe("ClineAccountService.fetchOverbudgetStatusRPC", () => {
let service: ClineAccountService
let sandbox: sinon.SinonSandbox
beforeEach(() => {
sandbox = sinon.createSandbox()
sandbox.stub(AuthService, "getInstance").returns({} as AuthService)
service = new ClineAccountService()
})
afterEach(() => {
sandbox.restore()
})
const buildAxiosError = (status: number): Error => {
const err = new Error(`Request failed with status code ${status}`) as Error & {
isAxiosError: boolean
response: { status: number }
}
err.isAxiosError = true
err.response = { status }
return err
}
it("returns the overbudget status on a successful authenticated request", async () => {
const payload = {
overbudget: true,
limits: { monthlyLimitUsd: 500, dailyLimitUsd: 50, orgMonthlyUsd: 5000, source: "org_default" },
usage: { monthlySpendUsd: 0, dailySpendUsd: 0 },
}
sandbox.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest").resolves(payload)
const result = await service.fetchOverbudgetStatusRPC("org-123")
assert.deepStrictEqual(result, payload)
})
it("returns undefined when the backend responds with 403 (feature not enabled)", async () => {
sandbox.stub(axios, "isAxiosError").returns(true)
sandbox
.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest")
.rejects(buildAxiosError(403))
const result = await service.fetchOverbudgetStatusRPC("org-123")
assert.strictEqual(result, undefined)
})
it("returns undefined when the backend responds with 404 (feature not enabled)", async () => {
sandbox.stub(axios, "isAxiosError").returns(true)
sandbox
.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest")
.rejects(buildAxiosError(404))
const result = await service.fetchOverbudgetStatusRPC("org-123")
assert.strictEqual(result, undefined)
})
it("returns undefined on transient network failure without throwing", async () => {
sandbox
.stub(service as unknown as { authenticatedRequest: () => unknown }, "authenticatedRequest")
.rejects(new Error("Network error"))
const result = await service.fetchOverbudgetStatusRPC("org-123")
assert.strictEqual(result, undefined)
})
})
@@ -0,0 +1,183 @@
import type { OverbudgetStatus } from "@shared/ClineAccount"
import * as assert from "assert"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { DEFAULT_SPEND_LIMIT_TTL_MS, ThirdPartySpendLimitService } from "@/services/spend-limit/ThirdPartySpendLimitService"
const SAMPLE_STATUS: OverbudgetStatus = {
overbudget: true,
limits: { monthlyLimitUsd: 500, dailyLimitUsd: 50, orgMonthlyUsd: 5000, source: "org_default" },
usage: { monthlySpendUsd: 0, dailySpendUsd: 0 },
}
describe("ThirdPartySpendLimitService", () => {
let sandbox: sinon.SinonSandbox
let svc: ThirdPartySpendLimitService
let fetchStub: sinon.SinonStub
let getActiveOrgStub: sinon.SinonStub
let fakeNow: number
beforeEach(() => {
sandbox = sinon.createSandbox()
getActiveOrgStub = sandbox.stub().returns("org-123")
sandbox.stub(AuthService, "getInstance").returns({ getActiveOrganizationId: getActiveOrgStub } as unknown as AuthService)
fetchStub = sandbox.stub(ClineAccountService.prototype, "fetchOverbudgetStatusRPC")
sandbox.stub(ClineAccountService, "getInstance").returns(new ClineAccountService())
// Reset singleton between tests via the public invalidate hook.
svc = ThirdPartySpendLimitService.getInstance()
svc.invalidate()
// Reset to the canonical default so each test starts from a known state.
svc.setTtlMs(DEFAULT_SPEND_LIMIT_TTL_MS)
// Controllable clock so we can exercise TTL behaviour deterministically.
fakeNow = 1_000_000
svc._setClockForTest(() => fakeNow)
})
afterEach(() => {
sandbox.restore()
svc.invalidate()
svc.setTtlMs(DEFAULT_SPEND_LIMIT_TTL_MS)
svc._setClockForTest(() => Date.now())
})
it("caches the status after the first fetch and does not re-fetch for the same org", async () => {
fetchStub.resolves(SAMPLE_STATUS)
await svc.fetchIfNeeded()
await svc.fetchIfNeeded()
await svc.fetchIfNeeded()
assert.strictEqual(fetchStub.callCount, 1, "should fetch exactly once per org per session")
assert.deepStrictEqual(svc.getStatus(), SAMPLE_STATUS)
assert.strictEqual(svc.isOverbudget(), true)
})
it("de-dupes concurrent fetches", async () => {
let resolveFetch: (value: OverbudgetStatus) => void = () => {}
fetchStub.returns(
new Promise<OverbudgetStatus>((resolve) => {
resolveFetch = resolve
}),
)
const [p1, p2, p3] = [svc.fetchIfNeeded(), svc.fetchIfNeeded(), svc.fetchIfNeeded()]
resolveFetch(SAMPLE_STATUS)
await Promise.all([p1, p2, p3])
assert.strictEqual(fetchStub.callCount, 1, "concurrent calls must share a single in-flight fetch")
})
it("re-fetches when the active org changes", async () => {
fetchStub.onFirstCall().resolves(SAMPLE_STATUS)
fetchStub.onSecondCall().resolves({ ...SAMPLE_STATUS, overbudget: false })
await svc.fetchIfNeeded()
assert.strictEqual(svc.isOverbudget(), true)
getActiveOrgStub.returns("org-456")
await svc.fetchIfNeeded()
assert.strictEqual(fetchStub.callCount, 2)
assert.strictEqual(svc.isOverbudget(), false)
})
it("clears the cache when there is no active org", async () => {
fetchStub.resolves(SAMPLE_STATUS)
await svc.fetchIfNeeded()
assert.ok(svc.getStatus())
getActiveOrgStub.returns(null)
await svc.fetchIfNeeded()
assert.strictEqual(svc.getStatus(), null)
})
it("does not throw when the underlying fetch fails", async () => {
fetchStub.rejects(new Error("boom"))
await svc.fetchIfNeeded()
assert.strictEqual(svc.getStatus(), null)
assert.strictEqual(svc.isOverbudget(), false)
})
it("treats an undefined RPC response (feature not enabled) as not-overbudget", async () => {
fetchStub.resolves(undefined)
await svc.fetchIfNeeded()
assert.strictEqual(svc.getStatus(), null)
assert.strictEqual(svc.isOverbudget(), false)
})
describe("TTL behaviour", () => {
it("does not refetch while the cached entry is still fresh", async () => {
svc.setTtlMs(60_000) // 1 minute
fetchStub.resolves(SAMPLE_STATUS)
await svc.fetchIfNeeded()
fakeNow += 30_000 // half the TTL
await svc.fetchIfNeeded()
assert.strictEqual(fetchStub.callCount, 1, "should be served from cache while fresh")
})
it("refetches once the TTL has elapsed for the same org", async () => {
svc.setTtlMs(60_000)
fetchStub.onFirstCall().resolves(SAMPLE_STATUS)
fetchStub.onSecondCall().resolves({ ...SAMPLE_STATUS, overbudget: false })
await svc.fetchIfNeeded()
assert.strictEqual(svc.isOverbudget(), true)
fakeNow += 60_001 // just past the TTL
await svc.fetchIfNeeded()
assert.strictEqual(fetchStub.callCount, 2, "should refetch after TTL expires")
assert.strictEqual(svc.isOverbudget(), false)
})
it("honours a 1-minute TTL override via setTtlMs (experimentation knob)", async () => {
svc.setTtlMs(60_000)
assert.strictEqual(svc.getTtlMs(), 60_000)
fetchStub.resolves(SAMPLE_STATUS)
await svc.fetchIfNeeded()
fakeNow += 59_999
await svc.fetchIfNeeded()
assert.strictEqual(fetchStub.callCount, 1, "still fresh at 59_999ms")
fakeNow += 2
await svc.fetchIfNeeded()
assert.strictEqual(fetchStub.callCount, 2, "stale at 60_001ms")
})
it("ttlMs=0 disables caching entirely (every call refetches)", async () => {
svc.setTtlMs(0)
fetchStub.resolves(SAMPLE_STATUS)
await svc.fetchIfNeeded()
await svc.fetchIfNeeded()
await svc.fetchIfNeeded()
assert.strictEqual(fetchStub.callCount, 3)
})
it("defaults to DEFAULT_SPEND_LIMIT_TTL_MS (5 minutes)", () => {
assert.strictEqual(svc.getTtlMs(), DEFAULT_SPEND_LIMIT_TTL_MS)
assert.strictEqual(DEFAULT_SPEND_LIMIT_TTL_MS, 5 * 60 * 1000)
})
it("rejects negative or non-finite TTL values", () => {
assert.throws(() => svc.setTtlMs(-1), /Invalid TTL/)
assert.throws(() => svc.setTtlMs(Number.NaN), /Invalid TTL/)
assert.throws(() => svc.setTtlMs(Number.POSITIVE_INFINITY), /Invalid TTL/)
})
})
})
@@ -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) {
@@ -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: {
@@ -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">
@@ -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