Skip to content

Commit f6a8bfc

Browse files
iicdiicursoragent
andauthored
fix(cursor): support Enterprise accounts with request-based usage (#118)
Enterprise accounts return no `enabled` or `planUsage` from the Connect API (GetCurrentPeriodUsage), causing "No active Cursor subscription." Changes: - Move GetPlanInfo call before subscription check for Enterprise detection - Add REST API fallback (/api/usage) for Enterprise request-based metrics - Construct session token from JWT sub claim for REST API auth - Display "Included requests" progress line (e.g. 422/500 requests) - Register new "Included requests" line in plugin.json manifest Closes #117 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 09187ec commit f6a8bfc

3 files changed

Lines changed: 216 additions & 4 deletions

File tree

plugins/cursor/plugin.js

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
const PLAN_URL = BASE_URL + "/aiserver.v1.DashboardService/GetPlanInfo"
77
const REFRESH_URL = BASE_URL + "/oauth/token"
88
const CREDITS_URL = BASE_URL + "/aiserver.v1.DashboardService/GetCreditGrantsBalance"
9+
const REST_USAGE_URL = "https://cursor.com/api/usage"
910
const CLIENT_ID = "KbZUR41cY7W6zRSdpSUJ7I7mLYBKOCmB"
1011
const REFRESH_BUFFER_MS = 5 * 60 * 1000 // refresh 5 minutes before expiration
1112

@@ -143,6 +144,82 @@
143144
})
144145
}
145146

147+
function buildSessionToken(ctx, accessToken) {
148+
var payload = ctx.jwt.decodePayload(accessToken)
149+
if (!payload || !payload.sub) return null
150+
var parts = String(payload.sub).split("|")
151+
var userId = parts.length > 1 ? parts[1] : parts[0]
152+
if (!userId) return null
153+
return { userId: userId, sessionToken: userId + "%3A%3A" + accessToken }
154+
}
155+
156+
function fetchEnterpriseUsage(ctx, accessToken) {
157+
var session = buildSessionToken(ctx, accessToken)
158+
if (!session) {
159+
ctx.host.log.warn("enterprise: cannot build session token")
160+
return null
161+
}
162+
try {
163+
var resp = ctx.util.request({
164+
method: "GET",
165+
url: REST_USAGE_URL + "?user=" + encodeURIComponent(session.userId),
166+
headers: {
167+
Cookie: "WorkosCursorSessionToken=" + session.sessionToken,
168+
},
169+
timeoutMs: 10000,
170+
})
171+
if (resp.status < 200 || resp.status >= 300) {
172+
ctx.host.log.warn("enterprise usage returned status=" + resp.status)
173+
return null
174+
}
175+
return ctx.util.tryParseJson(resp.bodyText)
176+
} catch (e) {
177+
ctx.host.log.warn("enterprise usage fetch failed: " + String(e))
178+
return null
179+
}
180+
}
181+
182+
function buildEnterpriseResult(ctx, accessToken, planName, usage) {
183+
var requestUsage = fetchEnterpriseUsage(ctx, accessToken)
184+
var lines = []
185+
186+
if (requestUsage) {
187+
var gpt4 = requestUsage["gpt-4"]
188+
if (gpt4 && typeof gpt4.maxRequestUsage === "number" && gpt4.maxRequestUsage > 0) {
189+
var used = gpt4.numRequests || 0
190+
var limit = gpt4.maxRequestUsage
191+
192+
var billingPeriodMs = 30 * 24 * 60 * 60 * 1000
193+
var cycleStart = requestUsage.startOfMonth
194+
? ctx.util.parseDateMs(requestUsage.startOfMonth)
195+
: null
196+
var cycleEndMs = cycleStart ? cycleStart + billingPeriodMs : null
197+
198+
lines.push(ctx.line.progress({
199+
label: "Included requests",
200+
used: used,
201+
limit: limit,
202+
format: { kind: "count", suffix: "requests" },
203+
resetsAt: ctx.util.toIso(cycleEndMs),
204+
periodDurationMs: billingPeriodMs,
205+
}))
206+
}
207+
}
208+
209+
if (lines.length === 0) {
210+
ctx.host.log.warn("enterprise: no usage data available")
211+
throw "Enterprise usage data unavailable. Try again later."
212+
}
213+
214+
var plan = null
215+
if (planName) {
216+
var planLabel = ctx.fmt.planLabel(planName)
217+
if (planLabel) plan = planLabel
218+
}
219+
220+
return { plan: plan, lines: lines }
221+
}
222+
146223
function probe(ctx) {
147224
let accessToken = readStateValue(ctx, "cursorAuth/accessToken")
148225
const refreshTokenValue = readStateValue(ctx, "cursorAuth/refreshToken")
@@ -221,10 +298,7 @@
221298
throw "Usage response invalid. Try again later."
222299
}
223300

224-
if (!usage.enabled || !usage.planUsage) {
225-
throw "No active Cursor subscription."
226-
}
227-
301+
// Fetch plan info early (needed for Enterprise detection)
228302
let planName = ""
229303
try {
230304
const planResp = connectPost(ctx, PLAN_URL, accessToken)
@@ -238,6 +312,18 @@
238312
ctx.host.log.warn("plan info fetch failed: " + String(e))
239313
}
240314

315+
// Enterprise accounts return no planUsage from the Connect API.
316+
// Detect Enterprise and use the REST usage API instead.
317+
const isEnterprise = !usage.planUsage && planName.toLowerCase() === "enterprise"
318+
if (isEnterprise) {
319+
ctx.host.log.info("detected enterprise account, using REST usage API")
320+
return buildEnterpriseResult(ctx, accessToken, planName, usage)
321+
}
322+
323+
if (!usage.enabled || !usage.planUsage) {
324+
throw "No active Cursor subscription."
325+
}
326+
241327
let creditGrants = null
242328
try {
243329
const creditsResp = connectPost(ctx, CREDITS_URL, accessToken)

plugins/cursor/plugin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"lines": [
1010
{ "type": "progress", "label": "Credits", "scope": "overview", "primaryOrder": 1 },
1111
{ "type": "progress", "label": "Plan usage", "scope": "overview", "primaryOrder": 2 },
12+
{ "type": "progress", "label": "Included requests", "scope": "overview", "primaryOrder": 3 },
1213
{ "type": "progress", "label": "On-demand", "scope": "detail" }
1314
]
1415
}

plugins/cursor/plugin.test.js

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,131 @@ describe("cursor plugin", () => {
193193
expect(() => plugin.probe(ctx)).toThrow("Usage response invalid")
194194
})
195195

196+
it("handles enterprise account with request-based usage", async () => {
197+
const ctx = makeCtx()
198+
199+
// Build a JWT with a sub claim containing a user ID
200+
const jwtPayload = Buffer.from(
201+
JSON.stringify({ sub: "google-oauth2|user_abc123", exp: 9999999999 }),
202+
"utf8"
203+
)
204+
.toString("base64")
205+
.replace(/=+$/g, "")
206+
const accessToken = `a.${jwtPayload}.c`
207+
208+
ctx.host.sqlite.query.mockReturnValue(
209+
JSON.stringify([{ value: accessToken }])
210+
)
211+
ctx.host.http.request.mockImplementation((opts) => {
212+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
213+
// Enterprise returns no enabled/planUsage
214+
return {
215+
status: 200,
216+
bodyText: JSON.stringify({
217+
billingCycleStart: "1770539602363",
218+
billingCycleEnd: "1770539602363",
219+
displayThreshold: 100,
220+
}),
221+
}
222+
}
223+
if (String(opts.url).includes("GetPlanInfo")) {
224+
return {
225+
status: 200,
226+
bodyText: JSON.stringify({
227+
planInfo: { planName: "Enterprise", price: "Custom" },
228+
}),
229+
}
230+
}
231+
if (String(opts.url).includes("cursor.com/api/usage")) {
232+
return {
233+
status: 200,
234+
bodyText: JSON.stringify({
235+
"gpt-4": {
236+
numRequests: 422,
237+
numRequestsTotal: 422,
238+
numTokens: 171664819,
239+
maxRequestUsage: 500,
240+
maxTokenUsage: null,
241+
},
242+
startOfMonth: "2026-02-01T06:12:57.000Z",
243+
}),
244+
}
245+
}
246+
return { status: 200, bodyText: "{}" }
247+
})
248+
const plugin = await loadPlugin()
249+
const result = plugin.probe(ctx)
250+
expect(result.plan).toBe("Enterprise")
251+
const reqLine = result.lines.find((l) => l.label === "Included requests")
252+
expect(reqLine).toBeTruthy()
253+
expect(reqLine.used).toBe(422)
254+
expect(reqLine.limit).toBe(500)
255+
expect(reqLine.format).toEqual({ kind: "count", suffix: "requests" })
256+
})
257+
258+
it("throws when enterprise REST usage API fails", async () => {
259+
const ctx = makeCtx()
260+
261+
const jwtPayload = Buffer.from(
262+
JSON.stringify({ sub: "google-oauth2|user_abc123", exp: 9999999999 }),
263+
"utf8"
264+
)
265+
.toString("base64")
266+
.replace(/=+$/g, "")
267+
const accessToken = `a.${jwtPayload}.c`
268+
269+
ctx.host.sqlite.query.mockReturnValue(
270+
JSON.stringify([{ value: accessToken }])
271+
)
272+
ctx.host.http.request.mockImplementation((opts) => {
273+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
274+
return {
275+
status: 200,
276+
bodyText: JSON.stringify({
277+
billingCycleStart: "1770539602363",
278+
billingCycleEnd: "1770539602363",
279+
}),
280+
}
281+
}
282+
if (String(opts.url).includes("GetPlanInfo")) {
283+
return {
284+
status: 200,
285+
bodyText: JSON.stringify({
286+
planInfo: { planName: "Enterprise" },
287+
}),
288+
}
289+
}
290+
if (String(opts.url).includes("cursor.com/api/usage")) {
291+
return { status: 500, bodyText: "" }
292+
}
293+
return { status: 200, bodyText: "{}" }
294+
})
295+
const plugin = await loadPlugin()
296+
expect(() => plugin.probe(ctx)).toThrow("Enterprise usage data unavailable")
297+
})
298+
299+
it("still throws no subscription for non-enterprise accounts without planUsage", async () => {
300+
const ctx = makeCtx()
301+
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))
302+
ctx.host.http.request.mockImplementation((opts) => {
303+
if (String(opts.url).includes("GetCurrentPeriodUsage")) {
304+
return {
305+
status: 200,
306+
bodyText: JSON.stringify({ enabled: false }),
307+
}
308+
}
309+
if (String(opts.url).includes("GetPlanInfo")) {
310+
return {
311+
status: 200,
312+
bodyText: JSON.stringify({ planInfo: { planName: "Pro" } }),
313+
}
314+
}
315+
return { status: 200, bodyText: "{}" }
316+
})
317+
const plugin = await loadPlugin()
318+
expect(() => plugin.probe(ctx)).toThrow("No active Cursor subscription.")
319+
})
320+
196321
it("handles plan fetch failure gracefully", async () => {
197322
const ctx = makeCtx()
198323
ctx.host.sqlite.query.mockReturnValue(JSON.stringify([{ value: "token" }]))

0 commit comments

Comments
 (0)