From f9ba0da9cf544492a1f797cdd2e4eb39d50c671c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:00:09 +0000 Subject: [PATCH] fix(server): Usage says when Cursor stops reporting recent history Cursor's GetFilteredUsageEvents RPC now answers `{}` for recent date ranges while its current billing cycle shows usage, so Cursor history in Usage silently stopped in early November. An empty page is indistinguishable from no usage, and no other history read accepts the API-key token. The account reader now also reads GetCurrentPeriodUsage. When Cursor reports usage in the current cycle but the cached history has no event since the cycle started, the Cursor source is marked partial with a notice, keeping the history it has. Fixes pingdotgg/t3code#28511 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/usage/UsageService.test.ts | 29 +++++++++++++- .../src/server/CursorUsageAccounts.ts | 12 +++++- .../src/server/accountUsage.test.ts | 32 +++++++++++++++- .../src/server/accountUsage.ts | 38 +++++++++++++++++++ .../provider-cursor/src/server/usageLimits.ts | 2 +- 5 files changed, 108 insertions(+), 5 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index be364729819c9..465a2db46de59 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -285,6 +285,7 @@ function makeFakeCursor() { accountKey: "account-a", events: [] as { readonly timestampMs: number; readonly outputTokens: number }[], error: null as string | null, + usageSinceMs: undefined as number | undefined, gate: undefined as Deferred.Deferred | undefined, calls: [] as { readonly sinceMs: number; readonly untilMs: number }[], credentials: [] as unknown[], @@ -320,7 +321,14 @@ function makeFakeCursor() { dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, }; }); - return { accountKey, records, missing: false, error: null }; + const { usageSinceMs } = state; + return { + accountKey, + records, + missing: false, + error: null, + ...(usageSinceMs === undefined ? {} : { usageSinceMs }), + }; }); return { state, read }; } @@ -1576,6 +1584,25 @@ describe("UsageService", () => { assert.deepStrictEqual(after.state.calls, [ { sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 }, ]); + + // Cursor reports usage since a cycle start that its usage events never reach. + after.state.usageSinceMs = CURSOR_NOW - 2 * HOUR_MS; + yield* TestClock.adjust(Duration.minutes(2)); + const stale = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.deepStrictEqual(stale.buckets, original.buckets); + assert.strictEqual(cursorSource(stale)?.status, "partial"); + assert.strictEqual( + cursorSource(stale)?.message, + "Cursor has not reported your recent usage history.", + ); + + after.state.events = [ + ...before.state.events, + { timestampMs: CURSOR_NOW + 3 * 60 * 1000, outputTokens: 2 }, + ]; + yield* TestClock.adjust(Duration.minutes(2)); + const caughtUp = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true }); + assert.strictEqual(cursorSource(caughtUp)?.status, "ok"); }).pipe( Effect.provide( layerService({ diff --git a/packages/provider-cursor/src/server/CursorUsageAccounts.ts b/packages/provider-cursor/src/server/CursorUsageAccounts.ts index 8c36c107a409c..90740723ac995 100644 --- a/packages/provider-cursor/src/server/CursorUsageAccounts.ts +++ b/packages/provider-cursor/src/server/CursorUsageAccounts.ts @@ -30,6 +30,7 @@ import * as CursorAccountReader from "./CursorAccountReader.ts"; import { readStoredCursorApiKey } from "./credentialStore.ts"; const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; +const CURSOR_ACCOUNT_STALE_ERROR = "Cursor has not reported your recent usage history."; const decodeCacheFile = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec), @@ -147,7 +148,16 @@ const make = Effect.gen(function* () { retentionStartMs, ); caches.set(credentialKey, merged.cache); - failures.delete(credentialKey); + const { usageSinceMs } = result; + // Cursor's usage-events RPC can stop returning recent events while the cycle shows usage. + if ( + usageSinceMs !== undefined && + !merged.cache.records.some((record) => record.timestampMs >= usageSinceMs) + ) { + failures.set(credentialKey, { atMs: nowMs, error: CURSOR_ACCOUNT_STALE_ERROR }); + } else { + failures.delete(credentialKey); + } if (merged.changed) cacheDirty = true; yield* schedulePersist; }, refreshLock.withPermit); diff --git a/packages/provider-cursor/src/server/accountUsage.test.ts b/packages/provider-cursor/src/server/accountUsage.test.ts index 94c65d546b957..1bd798d12e466 100644 --- a/packages/provider-cursor/src/server/accountUsage.test.ts +++ b/packages/provider-cursor/src/server/accountUsage.test.ts @@ -20,7 +20,10 @@ interface DashboardRequest { readonly headers: Headers; } -const dashboard = (answer: (request: DashboardRequest) => Response | Promise) => +const dashboard = ( + answer: (request: DashboardRequest) => Response | Promise, + periodUsage: unknown = {}, +) => HttpClient.make((request, url, signal, fiber) => Effect.tryPromise({ try: async () => { @@ -36,6 +39,9 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise Response | Promise, + periodUsage?: unknown, ) => readCursorAccountUsage(credentialSource, sinceMs, endDate).pipe( - Effect.provideService(HttpClient.HttpClient, dashboard(answer)), + Effect.provideService(HttpClient.HttpClient, dashboard(answer, periodUsage)), ); describe("Cursor account history", () => { @@ -157,6 +164,27 @@ describe("Cursor account history", () => { assert.isNull(result.error); assert.deepStrictEqual(result.records, []); assert.isFalse(result.missing); + assert.isUndefined(result.usageSinceMs); + } + // The current billing cycle tells whether an empty history hides recent usage. + for (const [periodUsage, usageSinceMs] of [ + [ + { billingCycleStart: "1780000000000", planUsage: { totalPercentUsed: 2.9 } }, + 1780000000000, + ], + [{ billingCycleStart: "1780000000000", planUsage: { totalPercentUsed: 0 } }, undefined], + [{ billingCycleStart: "1780000000000" }, undefined], + [{ error: "upstream error" }, undefined], + ] as const) { + const result = yield* readAccountUsage( + credential, + 0, + 1781000000000, + async () => Response.json({}), + periodUsage, + ); + assert.isNull(result.error); + assert.strictEqual(result.usageSinceMs, usageSinceMs); } for (const body of [ { error: "upstream error" }, diff --git a/packages/provider-cursor/src/server/accountUsage.ts b/packages/provider-cursor/src/server/accountUsage.ts index dff8f3d877e24..177e534f487ab 100644 --- a/packages/provider-cursor/src/server/accountUsage.ts +++ b/packages/provider-cursor/src/server/accountUsage.ts @@ -7,11 +7,13 @@ import * as Fiber from "effect/Fiber"; import * as FetchHttpClient from "effect/http/FetchHttpClient"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import type { CursorCredentialSource } from "./accountCache.ts"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; +import { CursorUsageResponse } from "./usageLimits.ts"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) @@ -36,6 +38,8 @@ export interface CursorAccountUsageReadResult { readonly records: readonly UsageRecord[]; readonly missing: boolean; readonly error: string | null; + /** Start of the billing cycle in which Cursor reports usage; its history should reach it. */ + readonly usageSinceMs?: number; } class CursorAccountUsageInvalidError extends Schema.TaggedError()( @@ -112,6 +116,38 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi accountKey = yield* accountHash(subject); if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) return yield* invalid("Invalid date window"); + const periodUsage = yield* httpClient + .execute( + HttpClientRequest.post( + `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`, + ).pipe( + HttpClientRequest.bearerToken(accessToken), + HttpClientRequest.setHeaders({ + "connect-protocol-version": "1", + "x-cursor-client-type": "cli", + }), + HttpClientRequest.bodyJsonUnsafe({}), + ), + ) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(CursorUsageResponse)), + Effect.map(({ billingCycleStart, planUsage }) => { + const cycleStartMs = Number(billingCycleStart); + const used = [ + planUsage?.totalPercentUsed, + planUsage?.autoPercentUsed, + planUsage?.apiPercentUsed, + ].some((percent) => percent !== undefined && percent > 0); + return used && Number.isFinite(cycleStartMs) && cycleStartMs > 0 + ? cycleStartMs + : undefined; + }), + Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT), + Effect.timeout(Duration.seconds(10)), + Effect.orElseSucceed(() => undefined), + Effect.forkScoped, + ); const pageRequest = HttpClientRequest.post( `${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`, ).pipe( @@ -310,11 +346,13 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi "cursor.usage.events": rawCount, "cursor.usage.records": records.length, }); + const usageSinceMs = yield* Fiber.join(periodUsage); return { accountKey, records, missing: false, error: null, + ...(usageSinceMs === undefined ? {} : { usageSinceMs }), } satisfies CursorAccountUsageReadResult; }); return yield* read.pipe( diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index 48ab708554206..87a200b482727 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -15,7 +15,7 @@ import { } from "@t3tools/provider-core/server/usageLimits"; import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; -const CursorUsageResponse = Schema.Struct({ +export const CursorUsageResponse = Schema.Struct({ billingCycleStart: Schema.optional(Schema.Union([Schema.String, Schema.Number])), billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), planUsage: Schema.optional(