mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8588750283 | ||
|
|
31156470d4 | ||
|
|
ec7a296eed | ||
|
|
375bb2a93a | ||
|
|
812d75d0d2 |
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user