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(