| author | |
| committer | |
| log | 5a9c664b3ec8214cc5847d561efa130ba213eb38 |
| tree | 58910919f6b007f6c06fdf9f8576e1839ff6ec97 |
| parent | 23957c704b43c25a253baf2644829e88cf45917f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>28 files changed, 1706 insertions(+), 1405 deletions(-)
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+3| ... | ... | @@ -4,6 +4,7 @@ import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.t |
| 4 | 4 | import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 5 | 5 | import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; |
| 6 | 6 | import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; |
| 7 | import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; | |
| 7 | 8 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 8 | 9 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 9 | 10 | |
| ... | ... | @@ -12,6 +13,7 @@ export type ProviderOrchestrationAdapterInfrastructure = |
| 12 | 13 | | CodexAdapterV2.CodexAppServerClientFactory |
| 13 | 14 | | CursorAgentSdk.CursorAgentSdkRunner |
| 14 | 15 | | CursorSdk.CursorSdk |
| 16 | | CursorKeychain.CursorKeychain | |
| 15 | 17 | | IdAllocator.IdAllocatorV2; |
| 16 | 18 | |
| 17 | 19 | /** |
| ... | ... | @@ -24,6 +26,7 @@ export const layer = Layer.mergeAll( |
| 24 | 26 | ClaudeAdapterV2.layerQueryRunner, |
| 25 | 27 | CodexAdapterV2.layerAppServerClientFactory, |
| 26 | 28 | CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)), |
| 29 | CursorKeychain.layer, | |
| 27 | 30 | IdAllocator.layer, |
| 28 | 31 | ProviderContinuationRequests.layer, |
| 29 | 32 | ); |
apps/server/src/provider/builtInDrivers.ts+3-2| ... | ... | @@ -75,7 +75,8 @@ export type BuiltInUsageReadersEnv = |
| 75 | 75 | | ProviderUsageReaderEnv<typeof ClaudeDriver> |
| 76 | 76 | | ProviderUsageReaderEnv<typeof CodexDriver> |
| 77 | 77 | | ProviderUsageReaderEnv<typeof GrokDriver> |
| 78 | | ProviderUsageReaderEnv<typeof OpenCodeDriver>; | |
| 78 | | ProviderUsageReaderEnv<typeof OpenCodeDriver> | |
| 79 | | ProviderUsageReaderEnv<typeof CursorDriver>; | |
| 79 | 80 | |
| 80 | 81 | /** |
| 81 | 82 | * The drivers that keep usage history, in the order the usage page reads |
| ... | ... | @@ -84,4 +85,4 @@ export type BuiltInUsageReadersEnv = |
| 84 | 85 | */ |
| 85 | 86 | export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< |
| 86 | 87 | AnyProviderDriver<BuiltInDriversEnv, BuiltInUsageReadersEnv> |
| 87 | > = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver]; | |
| 88 | > = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver, CursorDriver]; |
apps/server/src/server.ts+12-3| ... | ... | @@ -154,7 +154,9 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien |
| 154 | 154 | import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; |
| 155 | 155 | import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; |
| 156 | 156 | import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; |
| 157 | import * as CursorUsageReader from "./usage/cursorUsageReader.ts"; | |
| 157 | import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; | |
| 158 | import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; | |
| 159 | import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; | |
| 158 | 160 | import * as UsageService from "./usage/UsageService.ts"; |
| 159 | 161 | import * as RuntimeLayer from "./orchestration-v2/runtimeLayer.ts"; |
| 160 | 162 | import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; |
| ... | ... | @@ -252,8 +254,13 @@ const layerBackground = BackgroundPolicy.layer.pipe( |
| 252 | 254 | ); |
| 253 | 255 | |
| 254 | 256 | const layerUsage = UsageService.layer.pipe( |
| 257 | Layer.provide( | |
| 258 | CursorUsageAccounts.layer.pipe( | |
| 259 | Layer.provide(CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer))), | |
| 260 | Layer.provide(ProviderHostLive.layer.pipe(Layer.provide(ServerSecretStore.layer))), | |
| 261 | ), | |
| 262 | ), | |
| 255 | 263 | Layer.provide(layerServerSettings), |
| 256 | Layer.provide(CursorUsageReader.layer), | |
| 257 | 264 | ); |
| 258 | 265 | |
| 259 | 266 | const layerResourceDiagnostics = Layer.mergeAll( |
| ... | ... | @@ -666,9 +673,11 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( |
| 666 | 673 | |
| 667 | 674 | const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( |
| 668 | 675 | // Misc. |
| 676 | // Usage reads provider history through the ProviderHost, which needs the | |
| 677 | // background policy below it. | |
| 678 | Layer.provideMerge(layerUsage), | |
| 669 | 679 | Layer.provideMerge(layerBackground), |
| 670 | 680 | Layer.provideMerge(layerResourceDiagnostics), |
| 671 | Layer.provideMerge(layerUsage), | |
| 672 | 681 | Layer.provideMerge(TraceDiagnostics.layer), |
| 673 | 682 | Layer.provideMerge(AnalyticsService.layer), |
| 674 | 683 | Layer.provideMerge(ExternalLauncher.layer), |
apps/server/src/usage/UsageService.test.ts+40-18| ... | ... | @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; |
| 24 | 24 | import * as Exit from "effect/Exit"; |
| 25 | 25 | import * as Fiber from "effect/Fiber"; |
| 26 | 26 | import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as Context from "effect/Context"; | |
| 27 | 28 | import * as Layer from "effect/Layer"; |
| 28 | 29 | import * as Scheduler from "effect/Scheduler"; |
| 29 | 30 | import * as Schema from "effect/Schema"; |
| ... | ... | @@ -32,7 +33,12 @@ import { HttpClient, HttpClientResponse } from "effect/http"; |
| 32 | 33 | |
| 33 | 34 | import * as ServerConfig from "../config.ts"; |
| 34 | 35 | import * as ServerSettings from "../serverSettings.ts"; |
| 35 | import * as CursorUsageReader from "./cursorUsageReader.ts"; | |
| 36 | import * as CursorAccountReader from "@t3tools/provider-cursor/server/CursorAccountReader"; | |
| 37 | import * as CursorKeychain from "@t3tools/provider-cursor/server/CursorKeychain"; | |
| 38 | import * as CursorUsageAccounts from "@t3tools/provider-cursor/server/CursorUsageAccounts"; | |
| 39 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 40 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | |
| 41 | import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; | |
| 36 | 42 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; |
| 37 | 43 | import * as UsageService from "./UsageService.ts"; |
| 38 | 44 | |
| ... | ... | @@ -87,6 +93,27 @@ const setup = Effect.gen(function* () { |
| 87 | 93 | }; |
| 88 | 94 | }); |
| 89 | 95 | |
| 96 | const layerCursorUsageAccounts = <E, R>( | |
| 97 | reader: Layer.Layer<CursorAccountReader.CursorAccountReader, E, R>, | |
| 98 | ) => Layer.fresh(CursorUsageAccounts.layer).pipe(Layer.provide(reader)); | |
| 99 | ||
| 100 | /** | |
| 101 | * A service with its own Cursor account caches over `read`, as a fresh server | |
| 102 | * process has. `awaitPersisted` also waits for the account cache writes. | |
| 103 | */ | |
| 104 | const makeWithCursor = (read: CursorAccountReader.CursorAccountReader["Service"]["read"]) => | |
| 105 | Effect.gen(function* () { | |
| 106 | const context = yield* Layer.build( | |
| 107 | layerCursorUsageAccounts(Layer.succeed(CursorAccountReader.CursorAccountReader, { read })), | |
| 108 | ); | |
| 109 | const accounts = Context.get(context, CursorUsageAccounts.CursorUsageAccounts); | |
| 110 | const service = yield* UsageService.make.pipe(Effect.provideContext(context)); | |
| 111 | return { | |
| 112 | ...service, | |
| 113 | awaitPersisted: Effect.andThen(service.awaitPersisted, accounts.awaitPersisted), | |
| 114 | }; | |
| 115 | }); | |
| 116 | ||
| 90 | 117 | const layerService = (input: { |
| 91 | 118 | readonly prefix: string; |
| 92 | 119 | readonly home: string; |
| ... | ... | @@ -97,9 +124,14 @@ const layerService = (input: { |
| 97 | 124 | readonly environment?: NodeJS.ProcessEnv; |
| 98 | 125 | readonly platform?: NodeJS.Platform; |
| 99 | 126 | }) => |
| 100 | ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( | |
| 127 | layerCursorUsageAccounts( | |
| 128 | CursorAccountReader.layer.pipe(Layer.provide(CursorKeychain.layer)), | |
| 129 | ).pipe( | |
| 130 | Layer.provideMerge(ProviderHostLive.layer), | |
| 131 | Layer.provideMerge(Layer.mock(BackgroundPolicy.BackgroundPolicy)({})), | |
| 132 | Layer.provideMerge(Layer.mock(ServerSecretStore.ServerSecretStore)({})), | |
| 133 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: input.prefix })), | |
| 101 | 134 | Layer.provideMerge(NodeServices.layer), |
| 102 | Layer.provideMerge(CursorUsageReader.layer), | |
| 103 | 135 | Layer.provideMerge(Layer.succeed(HostProcessPlatform, input.platform ?? "linux")), |
| 104 | 136 | Layer.provideMerge(ServerSettings.layerTest(input.settings)), |
| 105 | 137 | Layer.provideMerge( |
| ... | ... | @@ -364,9 +396,7 @@ describe("UsageService", () => { |
| 364 | 396 | const gate = yield* Deferred.make<void>(); |
| 365 | 397 | cursor.state.gate = gate; |
| 366 | 398 | yield* Effect.gen(function* () { |
| 367 | const service = yield* UsageService.make.pipe( | |
| 368 | Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), | |
| 369 | ); | |
| 399 | const service = yield* makeWithCursor(cursor.read); | |
| 370 | 400 | // Cold: nothing cached yet, so Cursor answers empty while it refreshes. |
| 371 | 401 | const cold = yield* service.readSummary(WINDOW); |
| 372 | 402 | assert.strictEqual(cursorSource(cold)?.refreshing, true); |
| ... | ... | @@ -421,9 +451,7 @@ describe("UsageService", () => { |
| 421 | 451 | { timestampMs: CURSOR_NOW - HOUR_MS / 2, outputTokens: 7 }, |
| 422 | 452 | ]; |
| 423 | 453 | yield* Effect.gen(function* () { |
| 424 | const service = yield* UsageService.make.pipe( | |
| 425 | Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), | |
| 426 | ); | |
| 454 | const service = yield* makeWithCursor(cursor.read); | |
| 427 | 455 | const read = (input: UsageSummaryInput) => |
| 428 | 456 | service.readSummary({ ...input, awaitRefresh: true }); |
| 429 | 457 | assert.strictEqual(totalOutputTokens(yield* read(WINDOW)), 19); |
| ... | ... | @@ -470,9 +498,7 @@ describe("UsageService", () => { |
| 470 | 498 | const cursor = makeFakeCursor(); |
| 471 | 499 | cursor.state.events = [{ timestampMs: CURSOR_NOW - HOUR_MS * 3, outputTokens: 5 }]; |
| 472 | 500 | yield* Effect.gen(function* () { |
| 473 | const service = yield* UsageService.make.pipe( | |
| 474 | Effect.provideService(CursorUsageReader.CursorAccountReader, { read: cursor.read }), | |
| 475 | ); | |
| 501 | const service = yield* makeWithCursor(cursor.read); | |
| 476 | 502 | yield* service.readSummary({ ...WINDOW, awaitRefresh: true }); |
| 477 | 503 | |
| 478 | 504 | yield* TestClock.adjust(Duration.minutes(2)); |
| ... | ... | @@ -513,17 +539,13 @@ describe("UsageService", () => { |
| 513 | 539 | { timestampMs: CURSOR_NOW - 3 * HOUR_MS, outputTokens: 7 }, |
| 514 | 540 | ]; |
| 515 | 541 | yield* Effect.gen(function* () { |
| 516 | const first = yield* UsageService.make.pipe( | |
| 517 | Effect.provideService(CursorUsageReader.CursorAccountReader, { read: before.read }), | |
| 518 | ); | |
| 542 | const first = yield* makeWithCursor(before.read); | |
| 519 | 543 | const original = yield* first.readSummary({ ...WINDOW, awaitRefresh: true }); |
| 520 | 544 | yield* first.awaitPersisted; |
| 521 | 545 | |
| 522 | 546 | const after = makeFakeCursor(); |
| 523 | 547 | after.state.events = before.state.events; |
| 524 | const restarted = yield* UsageService.make.pipe( | |
| 525 | Effect.provideService(CursorUsageReader.CursorAccountReader, { read: after.read }), | |
| 526 | ); | |
| 548 | const restarted = yield* makeWithCursor(after.read); | |
| 527 | 549 | const restored = yield* restarted.readSummary(WINDOW); |
| 528 | 550 | assert.isUndefined(cursorSource(restored)?.refreshing); |
| 529 | 551 | assert.deepStrictEqual(restored.buckets, original.buckets); |
apps/server/src/usage/UsageService.ts+13-272| ... | ... | @@ -12,9 +12,9 @@ |
| 12 | 12 | * remain visible. Antigravity databases are memoised in memory while the |
| 13 | 13 | * database and its WAL keep the same `(size, mtime, ctime)`. |
| 14 | 14 | * |
| 15 | * Cursor's account API is slow, so its source answers from a cache and marks | |
| 16 | * itself `refreshing` while a background refresh runs; `awaitRefresh` waits | |
| 17 | * for that refresh instead. See `cursorAccountCache`. | |
| 15 | * A scan reader with a slow source (an account API) may answer from its own | |
| 16 | * cache and mark itself `refreshing` while a background refresh runs; | |
| 17 | * `awaitRefresh` waits for that refresh instead. | |
| 18 | 18 | * |
| 19 | 19 | * @module UsageService |
| 20 | 20 | */ |
| ... | ... | @@ -32,7 +32,7 @@ import { |
| 32 | 32 | type UsageSummaryInput, |
| 33 | 33 | UsageReadError, |
| 34 | 34 | } from "@t3tools/contracts"; |
| 35 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 35 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 36 | 36 | import * as Cause from "effect/Cause"; |
| 37 | 37 | import * as Clock from "effect/Clock"; |
| 38 | 38 | import * as Context from "effect/Context"; |
| ... | ... | @@ -64,18 +64,6 @@ import type { |
| 64 | 64 | UsageRecord, |
| 65 | 65 | } from "@t3tools/provider-core/server/usage"; |
| 66 | 66 | import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; |
| 67 | import { | |
| 68 | CURSOR_ACCOUNT_CACHE_FILE_NAME, | |
| 69 | CURSOR_ACCOUNT_TTL_MS, | |
| 70 | cursorFetchRange, | |
| 71 | decodeCursorAccountCaches, | |
| 72 | encodeCursorAccountCaches, | |
| 73 | isCursorCacheFresh, | |
| 74 | mergeCursorFetch, | |
| 75 | type CursorAccountCache, | |
| 76 | type CursorCredentialSource, | |
| 77 | } from "./cursorAccountCache.ts"; | |
| 78 | import * as CursorUsageReader from "./cursorUsageReader.ts"; | |
| 79 | 67 | import { resolveModelAliases, UsageAggregator } from "./usageAggregation.ts"; |
| 80 | 68 | import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; |
| 81 | 69 | import { |
| ... | ... | @@ -116,8 +104,6 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; |
| 116 | 104 | */ |
| 117 | 105 | const CACHE_RETENTION_DAYS = 92; |
| 118 | 106 | |
| 119 | const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; | |
| 120 | ||
| 121 | 107 | /** Transcripts parsed at once. More gains little once the disk stays busy. */ |
| 122 | 108 | const TRANSCRIPT_READ_CONCURRENCY = 4; |
| 123 | 109 | |
| ... | ... | @@ -248,8 +234,6 @@ export const make = Effect.gen(function* () { |
| 248 | 234 | const settingsService = yield* ServerSettings.ServerSettingsService; |
| 249 | 235 | const httpClient = yield* HttpClient.HttpClient; |
| 250 | 236 | const hostEnvironment = yield* HostProcessEnvironment; |
| 251 | const platform = yield* HostProcessPlatform; | |
| 252 | const cursorAccountReader = yield* CursorUsageReader.CursorAccountReader; | |
| 253 | 237 | // The readers yield their own services; scans run them against this context. |
| 254 | 238 | const readerContext = yield* Effect.context<BuiltInUsageReadersEnv>(); |
| 255 | 239 | |
| ... | ... | @@ -257,20 +241,6 @@ export const make = Effect.gen(function* () { |
| 257 | 241 | const antigravityCache = makeAntigravityUsageCache(); |
| 258 | 242 | const sourceCache = new Map<string, typeof CachedSource.Type>(); |
| 259 | 243 | let cacheDirty = false; |
| 260 | /** Cursor account caches by credential source. */ | |
| 261 | const cursorCaches = new Map<string, CursorAccountCache>(); | |
| 262 | let cursorCacheDirty = false; | |
| 263 | /** | |
| 264 | * The last failed refresh per credential source, standing for a TTL so a | |
| 265 | * client refetching a broken login does not refetch Cursor each time. A | |
| 266 | * `null` error means there is no login: no source to report. | |
| 267 | */ | |
| 268 | const cursorFailures = new Map< | |
| 269 | string, | |
| 270 | { readonly atMs: number; readonly error: string | null } | |
| 271 | >(); | |
| 272 | /** The refresh in flight per credential source, which every read joins. */ | |
| 273 | const cursorRefreshes = new Map<string, Deferred.Deferred<void>>(); | |
| 274 | 244 | const isWithinDirectory = (filePath: string, dir: string) => { |
| 275 | 245 | const relative = path.relative(dir, filePath); |
| 276 | 246 | return relative !== ".." && !relative.startsWith(".." + path.sep) && !path.isAbsolute(relative); |
| ... | ... | @@ -279,7 +249,6 @@ export const make = Effect.gen(function* () { |
| 279 | 249 | const ratesCachePath = path.join(config.stateDir, "usage-model-rates.json"); |
| 280 | 250 | const scanCachePath = path.join(config.stateDir, SCAN_CACHE_FILE_NAME); |
| 281 | 251 | const legacyScanCachePath = path.join(config.stateDir, LEGACY_SCAN_CACHE_FILE_NAME); |
| 282 | const cursorCachePath = path.join(config.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); | |
| 283 | 252 | const writeCacheFile = (filePath: string, contents: string) => |
| 284 | 253 | writeFileStringAtomically({ filePath, contents }).pipe( |
| 285 | 254 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| ... | ... | @@ -453,7 +422,7 @@ export const make = Effect.gen(function* () { |
| 453 | 422 | }); |
| 454 | 423 | |
| 455 | 424 | /** |
| 456 | * Loads the persisted scan and Cursor account caches exactly once per process. | |
| 425 | * Loads the persisted scan cache exactly once per process. | |
| 457 | 426 | * |
| 458 | 427 | * `Effect.cached` makes concurrent first readers await the same load rather |
| 459 | 428 | * than each seeing a "loaded" flag set before the read finished and cold |
| ... | ... | @@ -466,9 +435,6 @@ export const make = Effect.gen(function* () { |
| 466 | 435 | Effect.flatMap((raw) => decodeScanCacheFile(raw)), |
| 467 | 436 | Effect.catchCause(() => Effect.succeed(null)), |
| 468 | 437 | ); |
| 469 | for (const [key, cache] of decodeCursorAccountCaches(yield* readDocument(cursorCachePath))) { | |
| 470 | cursorCaches.set(key, cache); | |
| 471 | } | |
| 472 | 438 | let document = yield* readDocument(scanCachePath); |
| 473 | 439 | if (document === null) { |
| 474 | 440 | document = yield* readDocument(legacyScanCachePath); |
| ... | ... | @@ -492,7 +458,7 @@ export const make = Effect.gen(function* () { |
| 492 | 458 | // keeps an older snapshot from landing after a newer one. |
| 493 | 459 | const persistLock = yield* Semaphore.make(1); |
| 494 | 460 | |
| 495 | // Each dirty flag is cleared before encoding, so a change while the write is | |
| 461 | // The dirty flag is cleared before encoding, so a change while the write is | |
| 496 | 462 | // in flight marks it dirty again. A failed write restores the flag, so the |
| 497 | 463 | // next persist retries instead of leaving disk stale. A cache we cannot |
| 498 | 464 | // write is a slower next start, not a failed read. |
| ... | ... | @@ -510,17 +476,6 @@ export const make = Effect.gen(function* () { |
| 510 | 476 | ), |
| 511 | 477 | ); |
| 512 | 478 | } |
| 513 | if (cursorCacheDirty) { | |
| 514 | cursorCacheDirty = false; | |
| 515 | yield* Effect.sync(() => encodeCursorAccountCaches(cursorCaches)).pipe( | |
| 516 | Effect.flatMap((contents) => writeCacheFile(cursorCachePath, contents)), | |
| 517 | Effect.catchCause(() => | |
| 518 | Effect.sync(() => { | |
| 519 | cursorCacheDirty = true; | |
| 520 | }), | |
| 521 | ), | |
| 522 | ); | |
| 523 | } | |
| 524 | 479 | }).pipe(persistLock.withPermit, Effect.withSpan("UsageService.persistCaches")); |
| 525 | 480 | |
| 526 | 481 | const pendingPersists = new Set<Fiber.Fiber<void>>(); |
| ... | ... | @@ -674,162 +629,6 @@ export const make = Effect.gen(function* () { |
| 674 | 629 | return { provider, dir, volumeId, files: parsedFiles } satisfies ScannedDir; |
| 675 | 630 | }); |
| 676 | 631 | |
| 677 | /** Fetches what one account cache is missing, then persists it if anything changed. */ | |
| 678 | const refreshCursorAccount = Effect.fn("UsageService.refreshCursorAccount")(function* ( | |
| 679 | credential: CursorCredentialSource, | |
| 680 | credentialKey: string, | |
| 681 | retentionStartMs: number, | |
| 682 | ) { | |
| 683 | const nowMs = yield* Clock.currentTimeMillis; | |
| 684 | const fetchMissing = (cache: CursorAccountCache | undefined) => { | |
| 685 | const range = cursorFetchRange(cache, retentionStartMs, nowMs); | |
| 686 | return cursorAccountReader | |
| 687 | .read(credential, range.sinceMs, range.untilMs) | |
| 688 | .pipe(Effect.map((result) => ({ range, result }))); | |
| 689 | }; | |
| 690 | let base = cursorCaches.get(credentialKey); | |
| 691 | let fetched = yield* fetchMissing(base); | |
| 692 | // Another login's history replaces the cached account's, even if reading it fails. | |
| 693 | if ( | |
| 694 | base !== undefined && | |
| 695 | fetched.result.accountKey !== null && | |
| 696 | fetched.result.accountKey !== base.accountKey | |
| 697 | ) { | |
| 698 | cursorCaches.delete(credentialKey); | |
| 699 | cursorCacheDirty = true; | |
| 700 | base = undefined; | |
| 701 | fetched = yield* fetchMissing(base); | |
| 702 | } | |
| 703 | const { range, result } = fetched; | |
| 704 | if (result.missing || result.error !== null || result.accountKey === null) { | |
| 705 | cursorFailures.set(credentialKey, { | |
| 706 | atMs: nowMs, | |
| 707 | error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR, | |
| 708 | }); | |
| 709 | yield* schedulePersist; | |
| 710 | return; | |
| 711 | } | |
| 712 | const merged = mergeCursorFetch( | |
| 713 | base, | |
| 714 | result.accountKey, | |
| 715 | range, | |
| 716 | result.records, | |
| 717 | nowMs, | |
| 718 | retentionStartMs, | |
| 719 | ); | |
| 720 | cursorCaches.set(credentialKey, merged.cache); | |
| 721 | cursorFailures.delete(credentialKey); | |
| 722 | // An unchanged edge is not worth rewriting the file for: after a restart | |
| 723 | // the cache just refetches a slightly wider edge. | |
| 724 | if (merged.changed) { | |
| 725 | cursorCacheDirty = true; | |
| 726 | yield* schedulePersist; | |
| 727 | } | |
| 728 | }); | |
| 729 | ||
| 730 | /** Joins the refresh in flight, or starts one. */ | |
| 731 | const startCursorRefresh = ( | |
| 732 | credential: CursorCredentialSource, | |
| 733 | credentialKey: string, | |
| 734 | retentionStartMs: number, | |
| 735 | ) => | |
| 736 | // Enrollment and fork are atomic, so an interrupted caller cannot leave a | |
| 737 | // registered refresh that nothing will finish. | |
| 738 | Effect.uninterruptible( | |
| 739 | Effect.gen(function* () { | |
| 740 | const current = cursorRefreshes.get(credentialKey); | |
| 741 | if (current !== undefined) return current; | |
| 742 | const done = Deferred.makeUnsafe<void>(); | |
| 743 | cursorRefreshes.set(credentialKey, done); | |
| 744 | // Detached: a departing client must not cancel a fetch later reads reuse. | |
| 745 | yield* refreshCursorAccount(credential, credentialKey, retentionStartMs).pipe( | |
| 746 | Effect.catchCause(() => | |
| 747 | Clock.currentTimeMillis.pipe( | |
| 748 | Effect.map((atMs) => | |
| 749 | cursorFailures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }), | |
| 750 | ), | |
| 751 | ), | |
| 752 | ), | |
| 753 | Effect.ensuring( | |
| 754 | Effect.suspend(() => { | |
| 755 | cursorRefreshes.delete(credentialKey); | |
| 756 | return Deferred.succeed(done, undefined); | |
| 757 | }), | |
| 758 | ), | |
| 759 | Effect.forkDetach, | |
| 760 | ); | |
| 761 | return done; | |
| 762 | }), | |
| 763 | ); | |
| 764 | ||
| 765 | /** | |
| 766 | * The Cursor account source. A cache inside its TTL, or a refresh that failed | |
| 767 | * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh` | |
| 768 | * waits for it, and anything else answers from the cache marked `refreshing`. | |
| 769 | */ | |
| 770 | const cursorAccountSource = Effect.fn("UsageService.cursorAccountSource")(function* ( | |
| 771 | credential: CursorCredentialSource, | |
| 772 | authPath: string, | |
| 773 | windowStartMs: number, | |
| 774 | retentionStartMs: number, | |
| 775 | awaitRefresh: boolean, | |
| 776 | ) { | |
| 777 | // No saved login means there is no account source to report, not a setup error. | |
| 778 | if ( | |
| 779 | typeof credential === "string" && | |
| 780 | !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) | |
| 781 | ) { | |
| 782 | return null; | |
| 783 | } | |
| 784 | const credentialKey = typeof credential === "string" ? credential : "keychain"; | |
| 785 | const nowMs = yield* Clock.currentTimeMillis; | |
| 786 | const recentFailure = cursorFailures.get(credentialKey); | |
| 787 | let refreshing = false; | |
| 788 | if ( | |
| 789 | (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && | |
| 790 | !isCursorCacheFresh(cursorCaches.get(credentialKey), nowMs) | |
| 791 | ) { | |
| 792 | const refresh = yield* startCursorRefresh(credential, credentialKey, retentionStartMs); | |
| 793 | if (awaitRefresh) yield* Deferred.await(refresh); | |
| 794 | else refreshing = true; | |
| 795 | } | |
| 796 | ||
| 797 | const cache = cursorCaches.get(credentialKey); | |
| 798 | const failure = refreshing ? undefined : cursorFailures.get(credentialKey); | |
| 799 | const failureMessage = failure === undefined ? undefined : failure.error; | |
| 800 | if (failureMessage === null) return null; | |
| 801 | if (cache === undefined) { | |
| 802 | return { | |
| 803 | provider: "cursor", | |
| 804 | dir: authPath, | |
| 805 | volumeId: yield* Effect.promise(() => readDirectoryVolumeId(authPath)), | |
| 806 | // Never combine a local fallback with another server's account-wide history. | |
| 807 | ...(refreshing | |
| 808 | ? { files: [], refreshing: true } | |
| 809 | : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), | |
| 810 | } satisfies ScannedDir; | |
| 811 | } | |
| 812 | // The same account includes CLI and desktop history from every machine. | |
| 813 | // A stable remote fingerprint prevents connected environments counting it twice. | |
| 814 | const source = `cursor-account:${cache.accountKey}`; | |
| 815 | return { | |
| 816 | provider: "cursor", | |
| 817 | dir: source, | |
| 818 | hostId: "cursor.com", | |
| 819 | volumeId: cache.accountKey, | |
| 820 | files: [ | |
| 821 | { | |
| 822 | path: source, | |
| 823 | records: cache.records.filter((record) => record.timestampMs >= windowStartMs), | |
| 824 | }, | |
| 825 | ], | |
| 826 | ...(failureMessage === undefined | |
| 827 | ? { status: "ok" } | |
| 828 | : { status: "partial", message: failureMessage }), | |
| 829 | ...(refreshing ? { refreshing: true } : {}), | |
| 830 | } satisfies ScannedDir; | |
| 831 | }); | |
| 832 | ||
| 833 | 632 | const collectDirs = Effect.fn("UsageService.collectDirs")(function* ( |
| 834 | 633 | windowStartMs: number, |
| 835 | 634 | settings: ServerSettingsValue, |
| ... | ... | @@ -862,6 +661,7 @@ export const make = Effect.gen(function* () { |
| 862 | 661 | reader |
| 863 | 662 | .scan({ |
| 864 | 663 | instances: usageInstances(driver, settings), |
| 664 | settings, | |
| 865 | 665 | windowStartMs, |
| 866 | 666 | retentionCutoffMs, |
| 867 | 667 | awaitRefresh, |
| ... | ... | @@ -884,7 +684,7 @@ export const make = Effect.gen(function* () { |
| 884 | 684 | Effect.provideContext(readerContext), |
| 885 | 685 | ), |
| 886 | 686 | { concurrency: "unbounded" }, |
| 887 | ).pipe(Effect.map((sources) => sources.flat())); | |
| 687 | ); | |
| 888 | 688 | |
| 889 | 689 | const antigravity = Effect.gen(function* () { |
| 890 | 690 | const antigravityRoots = yield* envRoots("ANTIGRAVITY_DATA_DIR", [ |
| ... | ... | @@ -949,78 +749,19 @@ export const make = Effect.gen(function* () { |
| 949 | 749 | return scanned; |
| 950 | 750 | }); |
| 951 | 751 | |
| 952 | const cursor = Effect.gen(function* () { | |
| 953 | const cursorUserHome = | |
| 954 | (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; | |
| 955 | const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); | |
| 956 | const cursorHome = | |
| 957 | platform === "darwin" | |
| 958 | ? path.join(cursorUserHome, "Library", "Application Support") | |
| 959 | : platform === "win32" | |
| 960 | ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") | |
| 961 | : configHome && path.isAbsolute(configHome) | |
| 962 | ? configHome | |
| 963 | : path.join(cursorUserHome, ".config"); | |
| 964 | const cursorAuthPath = | |
| 965 | platform === "darwin" | |
| 966 | ? path.join(cursorUserHome, ".cursor", "auth.json") | |
| 967 | : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); | |
| 968 | const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; | |
| 969 | const loginUnavailable = | |
| 970 | Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || | |
| 971 | Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || | |
| 972 | credentialStore === "memory"; | |
| 973 | const useKeychain = platform === "darwin" && credentialStore !== "file"; | |
| 974 | if (useKeychain && !loginUnavailable && !settings.cursorKeychainUsageEnabled) { | |
| 975 | return [ | |
| 976 | { | |
| 977 | provider: "cursor", | |
| 978 | dir: cursorAuthPath, | |
| 979 | volumeId: "", | |
| 980 | files: null, | |
| 981 | message: "Cursor account usage is off on this environment.", | |
| 982 | action: "enableCursorKeychain", | |
| 983 | } satisfies ScannedDir, | |
| 984 | ]; | |
| 985 | } | |
| 986 | if (loginUnavailable) { | |
| 987 | return [ | |
| 988 | { | |
| 989 | provider: "cursor", | |
| 990 | dir: cursorAuthPath, | |
| 991 | volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), | |
| 992 | files: null, | |
| 993 | message: "Cursor account history needs a Cursor CLI login on this server.", | |
| 994 | } satisfies ScannedDir, | |
| 995 | ]; | |
| 996 | } | |
| 997 | const source = yield* cursorAccountSource( | |
| 998 | useKeychain ? { kind: "keychain" } : cursorAuthPath, | |
| 999 | cursorAuthPath, | |
| 1000 | windowStartMs, | |
| 1001 | retentionCutoffMs, | |
| 1002 | awaitRefresh, | |
| 1003 | ); | |
| 1004 | return source === null ? [] : [source]; | |
| 1005 | }); | |
| 1006 | ||
| 1007 | 752 | // Independent sources scan together. Transcript directories go one at a |
| 1008 | 753 | // time, so open files stay at `TRANSCRIPT_READ_CONCURRENCY`. The result |
| 1009 | 754 | // keeps this order, since aggregation keeps the first copy of a duplicate. |
| 1010 | const [transcripts, scanDirs, antigravityDirs, cursorDirs] = yield* Effect.all( | |
| 1011 | [ | |
| 1012 | Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), | |
| 1013 | scans, | |
| 1014 | antigravity, | |
| 1015 | cursor, | |
| 1016 | ], | |
| 755 | const [transcripts, scanDirs, antigravityDirs] = yield* Effect.all( | |
| 756 | [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans, antigravity], | |
| 1017 | 757 | { concurrency: "unbounded" }, |
| 1018 | 758 | ); |
| 759 | // Antigravity reads between the other scan readers until it has a reader of its own. | |
| 1019 | 760 | const scanned: readonly ScannedDir[] = [ |
| 1020 | 761 | ...transcripts, |
| 1021 | ...scanDirs, | |
| 762 | ...scanDirs.slice(0, -1).flat(), | |
| 1022 | 763 | ...antigravityDirs, |
| 1023 | ...cursorDirs, | |
| 764 | ...(scanDirs.at(-1) ?? []), | |
| 1024 | 765 | ]; |
| 1025 | 766 | return scanned; |
| 1026 | 767 | }); |
apps/server/src/usage/cursorAccountCache.test.ts deleted-80| ... | ... | @@ -1,80 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { cursorFetchRange, mergeCursorFetch } from "./cursorAccountCache.ts"; | |
| 4 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 5 | ||
| 6 | const DAY_MS = 24 * 60 * 60 * 1000; | |
| 7 | ||
| 8 | const record = (timestampMs: number): UsageRecord => ({ | |
| 9 | provider: "cursor", | |
| 10 | timestampMs, | |
| 11 | model: "claude-fable-5", | |
| 12 | sessionId: "conversation-1", | |
| 13 | totals: { | |
| 14 | uncachedInputTokens: 0, | |
| 15 | cachedInputTokens: 0, | |
| 16 | cacheCreationTokens: 0, | |
| 17 | outputTokens: 1, | |
| 18 | reasoningTokens: 0, | |
| 19 | }, | |
| 20 | reportedCostUsd: null, | |
| 21 | speed: "standard", | |
| 22 | dedupeKey: `cursor-account:a:${timestampMs}:0`, | |
| 23 | }); | |
| 24 | ||
| 25 | describe("cursorAccountCache", () => { | |
| 26 | it("refetches everything once the cache no longer reaches the retention start", () => { | |
| 27 | const cache = { | |
| 28 | accountKey: "a", | |
| 29 | sinceMs: 0, | |
| 30 | untilMs: 10 * DAY_MS, | |
| 31 | fetchedAtMs: 10 * DAY_MS, | |
| 32 | records: [record(5 * DAY_MS)], | |
| 33 | }; | |
| 34 | const nowMs = 100 * DAY_MS; | |
| 35 | const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs); | |
| 36 | assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs }); | |
| 37 | ||
| 38 | const { cache: merged } = mergeCursorFetch( | |
| 39 | cache, | |
| 40 | "a", | |
| 41 | range, | |
| 42 | [record(50 * DAY_MS)], | |
| 43 | nowMs, | |
| 44 | 10 * DAY_MS, | |
| 45 | ); | |
| 46 | assert.deepStrictEqual( | |
| 47 | merged.records.map((entry) => entry.timestampMs), | |
| 48 | [50 * DAY_MS], | |
| 49 | ); | |
| 50 | // Then only the newest edge. | |
| 51 | assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), { | |
| 52 | sinceMs: nowMs - 60 * 60 * 1000, | |
| 53 | untilMs: nowMs + DAY_MS, | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | it("reports an edge that only confirms the cache as unchanged", () => { | |
| 58 | const cache = { | |
| 59 | accountKey: "a", | |
| 60 | sinceMs: 0, | |
| 61 | untilMs: 10 * DAY_MS, | |
| 62 | fetchedAtMs: 10 * DAY_MS, | |
| 63 | records: [record(DAY_MS), record(10 * DAY_MS)], | |
| 64 | }; | |
| 65 | const range = cursorFetchRange(cache, 0, 11 * DAY_MS); | |
| 66 | assert.isFalse( | |
| 67 | mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed, | |
| 68 | ); | |
| 69 | assert.isTrue( | |
| 70 | mergeCursorFetch( | |
| 71 | cache, | |
| 72 | "a", | |
| 73 | range, | |
| 74 | [record(10 * DAY_MS), record(10.5 * DAY_MS)], | |
| 75 | 11 * DAY_MS, | |
| 76 | 0, | |
| 77 | ).changed, | |
| 78 | ); | |
| 79 | }); | |
| 80 | }); |
apps/server/src/usage/cursorAccountCache.ts deleted-271| ... | ... | @@ -1,271 +0,0 @@ |
| 1 | /** | |
| 2 | * Cursor account usage, cached per credential source. | |
| 3 | * | |
| 4 | * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched | |
| 5 | * records are kept for the whole retention period, whatever window was asked | |
| 6 | * for. After one full fetch, a refresh fetches only the newest edge. | |
| 7 | * | |
| 8 | * A record's dedupe key carries its timestamp and an occurrence index counted | |
| 9 | * within one fetch. A refetched range therefore replaces every cached record in | |
| 10 | * that range rather than adding to it, so each timestamp's records always come | |
| 11 | * from a single fetch and their occurrence indexes cannot collide. | |
| 12 | * | |
| 13 | * @module cursorAccountCache | |
| 14 | */ | |
| 15 | import { cursorRateModel } from "./cursorUsageReader.ts"; | |
| 16 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 17 | ||
| 18 | export type CursorCredentialSource = string | { readonly kind: "keychain" }; | |
| 19 | ||
| 20 | /** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */ | |
| 21 | export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; | |
| 22 | ||
| 23 | /** Dashboard events can be finalized after they were first read. */ | |
| 24 | const REFETCH_OVERLAP_MS = 60 * 60 * 1000; | |
| 25 | ||
| 26 | export interface CursorAccountCache { | |
| 27 | readonly accountKey: string; | |
| 28 | /** Inclusive range of event timestamps the records are complete for. */ | |
| 29 | readonly sinceMs: number; | |
| 30 | readonly untilMs: number; | |
| 31 | /** When the newest edge was fetched. */ | |
| 32 | readonly fetchedAtMs: number; | |
| 33 | readonly records: readonly UsageRecord[]; | |
| 34 | } | |
| 35 | ||
| 36 | interface CursorFetchRange { | |
| 37 | readonly sinceMs: number; | |
| 38 | readonly untilMs: number; | |
| 39 | } | |
| 40 | ||
| 41 | /** Whether the cache answers without a refresh. */ | |
| 42 | export function isCursorCacheFresh( | |
| 43 | cache: CursorAccountCache | undefined, | |
| 44 | nowMs: number, | |
| 45 | ): cache is CursorAccountCache { | |
| 46 | return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the | |
| 51 | * newest edge, or everything when the cache does not reach back that far. | |
| 52 | */ | |
| 53 | export function cursorFetchRange( | |
| 54 | cache: CursorAccountCache | undefined, | |
| 55 | retentionStartMs: number, | |
| 56 | nowMs: number, | |
| 57 | ): CursorFetchRange { | |
| 58 | return cache === undefined || | |
| 59 | cache.sinceMs > retentionStartMs || | |
| 60 | cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs | |
| 61 | ? { sinceMs: retentionStartMs, untilMs: nowMs } | |
| 62 | : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs }; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Applies a fetched range to the cache, replacing the cached records it covers | |
| 67 | * and dropping those before the retention start. Pass `undefined` for a cache | |
| 68 | * that belongs to another account. `changed` is false when the fetch only | |
| 69 | * confirmed the cached records, so there is nothing new to persist. | |
| 70 | */ | |
| 71 | export function mergeCursorFetch( | |
| 72 | cache: CursorAccountCache | undefined, | |
| 73 | accountKey: string, | |
| 74 | range: CursorFetchRange, | |
| 75 | fetched: readonly UsageRecord[], | |
| 76 | nowMs: number, | |
| 77 | retentionStartMs: number, | |
| 78 | ) { | |
| 79 | const inRange = (record: UsageRecord) => | |
| 80 | record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs; | |
| 81 | const previous = cache?.records ?? []; | |
| 82 | const added = fetched.filter(inRange); | |
| 83 | const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey)); | |
| 84 | const changed = | |
| 85 | cache === undefined || | |
| 86 | replacedKeys.size !== added.length || | |
| 87 | added.some((record) => !replacedKeys.has(record.dedupeKey)); | |
| 88 | return { | |
| 89 | changed, | |
| 90 | cache: { | |
| 91 | accountKey, | |
| 92 | sinceMs: retentionStartMs, | |
| 93 | untilMs: range.untilMs, | |
| 94 | fetchedAtMs: nowMs, | |
| 95 | records: [ | |
| 96 | ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs), | |
| 97 | ...added, | |
| 98 | ], | |
| 99 | } satisfies CursorAccountCache, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Each version writes its own file, as the scan cache does, so servers of | |
| 105 | * different versions sharing a state directory keep their own caches. | |
| 106 | */ | |
| 107 | export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; | |
| 108 | const CURSOR_ACCOUNT_CACHE_VERSION = 1; | |
| 109 | ||
| 110 | /** Positional rows with interned strings, as in the scan cache. */ | |
| 111 | type SerializedRecord = readonly [ | |
| 112 | timestampMs: number, | |
| 113 | modelIndex: number, | |
| 114 | sessionIndex: number, | |
| 115 | uncachedInputTokens: number, | |
| 116 | cachedInputTokens: number, | |
| 117 | cacheCreationTokens: number, | |
| 118 | outputTokens: number, | |
| 119 | reportedCostUsd: number | null, | |
| 120 | dedupeKey: string | null, | |
| 121 | ]; | |
| 122 | ||
| 123 | interface SerializedAccount { | |
| 124 | readonly accountKey: string; | |
| 125 | readonly sinceMs: number; | |
| 126 | readonly untilMs: number; | |
| 127 | readonly fetchedAtMs: number; | |
| 128 | readonly records: readonly SerializedRecord[]; | |
| 129 | } | |
| 130 | ||
| 131 | /** Serialises the caches by credential source. */ | |
| 132 | export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string { | |
| 133 | const strings: string[] = []; | |
| 134 | const indexes = new Map<string, number>(); | |
| 135 | const intern = (value: string) => { | |
| 136 | let index = indexes.get(value); | |
| 137 | if (index === undefined) { | |
| 138 | index = strings.length; | |
| 139 | strings.push(value); | |
| 140 | indexes.set(value, index); | |
| 141 | } | |
| 142 | return index; | |
| 143 | }; | |
| 144 | const accounts: Record<string, SerializedAccount> = {}; | |
| 145 | for (const [credentialKey, cache] of caches) { | |
| 146 | accounts[credentialKey] = { | |
| 147 | accountKey: cache.accountKey, | |
| 148 | sinceMs: cache.sinceMs, | |
| 149 | untilMs: cache.untilMs, | |
| 150 | fetchedAtMs: cache.fetchedAtMs, | |
| 151 | records: cache.records.map((record) => [ | |
| 152 | record.timestampMs, | |
| 153 | intern(record.model), | |
| 154 | intern(record.sessionId), | |
| 155 | record.totals.uncachedInputTokens, | |
| 156 | record.totals.cachedInputTokens, | |
| 157 | record.totals.cacheCreationTokens, | |
| 158 | record.totals.outputTokens, | |
| 159 | record.reportedCostUsd, | |
| 160 | record.dedupeKey, | |
| 161 | ]), | |
| 162 | }; | |
| 163 | } | |
| 164 | return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts }); | |
| 165 | } | |
| 166 | ||
| 167 | const isFiniteNumber = (value: unknown): value is number => | |
| 168 | typeof value === "number" && Number.isFinite(value); | |
| 169 | ||
| 170 | /** | |
| 171 | * Rebuilds the caches from a parsed document. A malformed account is dropped | |
| 172 | * whole, costing one refetch; keeping its survivors would leave a gap the | |
| 173 | * covered range claims is complete. | |
| 174 | */ | |
| 175 | export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> { | |
| 176 | const caches = new Map<string, CursorAccountCache>(); | |
| 177 | if (typeof document !== "object" || document === null) return caches; | |
| 178 | const root = document as { | |
| 179 | readonly version?: unknown; | |
| 180 | readonly strings?: unknown; | |
| 181 | readonly accounts?: unknown; | |
| 182 | }; | |
| 183 | const strings = root.strings; | |
| 184 | if ( | |
| 185 | root.version !== CURSOR_ACCOUNT_CACHE_VERSION || | |
| 186 | !Array.isArray(strings) || | |
| 187 | !strings.every((value): value is string => typeof value === "string") || | |
| 188 | typeof root.accounts !== "object" || | |
| 189 | root.accounts === null | |
| 190 | ) { | |
| 191 | return caches; | |
| 192 | } | |
| 193 | const stringAt = (index: unknown): string | undefined => | |
| 194 | typeof index === "number" ? strings[index] : undefined; | |
| 195 | ||
| 196 | const decodeRecord = (row: unknown): UsageRecord | null => { | |
| 197 | if (!Array.isArray(row)) return null; | |
| 198 | const [ | |
| 199 | timestampMs, | |
| 200 | modelIndex, | |
| 201 | sessionIndex, | |
| 202 | uncached, | |
| 203 | cached, | |
| 204 | creation, | |
| 205 | output, | |
| 206 | cost, | |
| 207 | key, | |
| 208 | ]: readonly unknown[] = row; | |
| 209 | const model = stringAt(modelIndex); | |
| 210 | const sessionId = stringAt(sessionIndex); | |
| 211 | if ( | |
| 212 | !isFiniteNumber(timestampMs) || | |
| 213 | model === undefined || | |
| 214 | sessionId === undefined || | |
| 215 | !isFiniteNumber(uncached) || | |
| 216 | !isFiniteNumber(cached) || | |
| 217 | !isFiniteNumber(creation) || | |
| 218 | !isFiniteNumber(output) || | |
| 219 | (cost !== null && !isFiniteNumber(cost)) || | |
| 220 | (key !== null && typeof key !== "string") | |
| 221 | ) { | |
| 222 | return null; | |
| 223 | } | |
| 224 | return { | |
| 225 | provider: "cursor", | |
| 226 | timestampMs, | |
| 227 | model, | |
| 228 | rateModel: cursorRateModel(model), | |
| 229 | sessionId, | |
| 230 | totals: { | |
| 231 | uncachedInputTokens: uncached, | |
| 232 | cachedInputTokens: cached, | |
| 233 | cacheCreationTokens: creation, | |
| 234 | outputTokens: output, | |
| 235 | reasoningTokens: 0, | |
| 236 | }, | |
| 237 | reportedCostUsd: cost, | |
| 238 | speed: "standard", | |
| 239 | dedupeKey: key, | |
| 240 | }; | |
| 241 | }; | |
| 242 | ||
| 243 | for (const [credentialKey, raw] of Object.entries(root.accounts)) { | |
| 244 | if (typeof raw !== "object" || raw === null) continue; | |
| 245 | const account = raw as Partial<Record<keyof SerializedAccount, unknown>>; | |
| 246 | if ( | |
| 247 | typeof account.accountKey !== "string" || | |
| 248 | !isFiniteNumber(account.sinceMs) || | |
| 249 | !isFiniteNumber(account.untilMs) || | |
| 250 | !isFiniteNumber(account.fetchedAtMs) || | |
| 251 | !Array.isArray(account.records) | |
| 252 | ) { | |
| 253 | continue; | |
| 254 | } | |
| 255 | const records: UsageRecord[] = []; | |
| 256 | for (const row of account.records) { | |
| 257 | const record = decodeRecord(row); | |
| 258 | if (record === null) break; | |
| 259 | records.push(record); | |
| 260 | } | |
| 261 | if (records.length !== account.records.length) continue; | |
| 262 | caches.set(credentialKey, { | |
| 263 | accountKey: account.accountKey, | |
| 264 | sinceMs: account.sinceMs, | |
| 265 | untilMs: account.untilMs, | |
| 266 | fetchedAtMs: account.fetchedAtMs, | |
| 267 | records, | |
| 268 | }); | |
| 269 | } | |
| 270 | return caches; | |
| 271 | } |
apps/server/src/usage/cursorUsageReader.test.ts deleted-93| ... | ... | @@ -1,93 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server"; | |
| 4 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; | |
| 5 | ||
| 6 | describe("readCursorAccountUsage", () => { | |
| 7 | it("asks for Keychain approval when the prompt goes unanswered", async () => { | |
| 8 | const result = await readCursorAccountUsage( | |
| 9 | { kind: "keychain" }, | |
| 10 | 0, | |
| 11 | 1, | |
| 12 | () => Promise.reject(new Error("no network expected")), | |
| 13 | () => Promise.reject(new CursorKeychainTimeoutError()), | |
| 14 | ); | |
| 15 | assert.deepStrictEqual(result, { | |
| 16 | accountKey: null, | |
| 17 | records: [], | |
| 18 | missing: false, | |
| 19 | error: "Allow Keychain access on the Mac running T3 Code, then refresh.", | |
| 20 | }); | |
| 21 | }); | |
| 22 | ||
| 23 | it("reads the pages behind the first together and keeps them in page order", async () => { | |
| 24 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 25 | let inFlight = 0; | |
| 26 | let mostInFlight = 0; | |
| 27 | const result = await readCursorAccountUsage( | |
| 28 | { kind: "keychain" }, | |
| 29 | 0, | |
| 30 | 1781000000000, | |
| 31 | async (_url, init) => { | |
| 32 | const page: number = JSON.parse(String(init.body)).page; | |
| 33 | inFlight++; | |
| 34 | mostInFlight = Math.max(mostInFlight, inFlight); | |
| 35 | // Later pages answer first, so record order cannot come from arrival order. | |
| 36 | for (let turn = page; turn < 9; turn++) await Promise.resolve(); | |
| 37 | inFlight--; | |
| 38 | return Response.json({ | |
| 39 | totalUsageEventsCount: 8001, | |
| 40 | usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ | |
| 41 | timestamp: String(1780000000000 + (page - 1) * 1000 + index), | |
| 42 | model: "gpt-5", | |
| 43 | tokenUsage: { inputTokens: 1 }, | |
| 44 | })), | |
| 45 | }); | |
| 46 | }, | |
| 47 | async () => accessToken, | |
| 48 | ); | |
| 49 | assert.isNull(result.error); | |
| 50 | assert.strictEqual(mostInFlight, 6); | |
| 51 | assert.deepStrictEqual( | |
| 52 | result.records.map((record) => record.timestampMs), | |
| 53 | Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), | |
| 54 | ); | |
| 55 | }); | |
| 56 | ||
| 57 | it("ends like a page-by-page read when a page fails, leaving no request open", async () => { | |
| 58 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 59 | const fullPage = { | |
| 60 | totalUsageEventsCount: 8001, | |
| 61 | usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), | |
| 62 | }; | |
| 63 | // Page 3 fails while the pages after it are still waiting on Cursor. | |
| 64 | for (const [secondPage, error] of [ | |
| 65 | [() => Response.json(fullPage), "Cursor account usage could not be read."], | |
| 66 | [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."], | |
| 67 | ] as const) { | |
| 68 | let open = 0; | |
| 69 | const result = await readCursorAccountUsage( | |
| 70 | { kind: "keychain" }, | |
| 71 | 0, | |
| 72 | 1781000000000, | |
| 73 | (_url, init) => { | |
| 74 | const page: number = JSON.parse(String(init.body)).page; | |
| 75 | if (page === 1) return Promise.resolve(Response.json(fullPage)); | |
| 76 | if (page === 2) return Promise.resolve(secondPage()); | |
| 77 | if (page === 3) return Promise.reject(new Error("connection reset")); | |
| 78 | open++; | |
| 79 | return new Promise((_resolve, reject) => { | |
| 80 | init.signal?.addEventListener("abort", () => { | |
| 81 | open--; | |
| 82 | reject(init.signal?.reason); | |
| 83 | }); | |
| 84 | }); | |
| 85 | }, | |
| 86 | async () => accessToken, | |
| 87 | ); | |
| 88 | assert.strictEqual(result.error, error); | |
| 89 | assert.deepStrictEqual(result.records, []); | |
| 90 | assert.strictEqual(open, 0); | |
| 91 | } | |
| 92 | }); | |
| 93 | }); |
apps/server/src/usage/cursorUsageReader.ts deleted-316| ... | ... | @@ -1,316 +0,0 @@ |
| 1 | // Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. | |
| 2 | // @effect-diagnostics nodeBuiltinImport:off | |
| 3 | import * as NodeFSP from "node:fs/promises"; | |
| 4 | import * as NodeCrypto from "node:crypto"; | |
| 5 | import * as NodeTimersPromises from "node:timers/promises"; | |
| 6 | ||
| 7 | import * as Context from "effect/Context"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | ||
| 11 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 12 | import { | |
| 13 | CursorKeychainTimeoutError, | |
| 14 | readMacCursorAccessToken, | |
| 15 | } from "@t3tools/provider-cursor/server"; | |
| 16 | ||
| 17 | function object(value: unknown): Record<string, unknown> { | |
| 18 | return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 19 | ? (value as Record<string, unknown>) | |
| 20 | : {}; | |
| 21 | } | |
| 22 | ||
| 23 | function tokens(value: unknown): number { | |
| 24 | return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; | |
| 25 | } | |
| 26 | ||
| 27 | /** | |
| 28 | * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`, | |
| 29 | * `claude-fable-5-1-thinking-high`) to the base model's rate-table key. | |
| 30 | * Grok resolves through xAI's first-party entry, which has no bare alias. | |
| 31 | */ | |
| 32 | export function cursorRateModel(model: string): string { | |
| 33 | const base = model | |
| 34 | .replace(/^cursor-/, "") | |
| 35 | .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, ""); | |
| 36 | return base.startsWith("grok-") ? `xai/${base}` : base; | |
| 37 | } | |
| 38 | ||
| 39 | export interface CursorAccountUsageReadResult { | |
| 40 | readonly accountKey: string | null; | |
| 41 | readonly records: readonly UsageRecord[]; | |
| 42 | readonly missing: boolean; | |
| 43 | readonly error: string | null; | |
| 44 | } | |
| 45 | ||
| 46 | const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); | |
| 47 | ||
| 48 | function canonicalJson(value: unknown): string { | |
| 49 | if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; | |
| 50 | if (value !== null && typeof value === "object") { | |
| 51 | return `{${Object.entries(value) | |
| 52 | .sort(([a], [b]) => a.localeCompare(b)) | |
| 53 | .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) | |
| 54 | .join(",")}}`; | |
| 55 | } | |
| 56 | return JSON.stringify(value) ?? "null"; | |
| 57 | } | |
| 58 | ||
| 59 | /** Find the longest exact suffix/prefix overlap in linear time. */ | |
| 60 | function boundaryOverlap(previous: readonly string[], current: readonly string[]): number { | |
| 61 | const sequence = [...current, "", ...previous]; | |
| 62 | const lengths = Array.from({ length: sequence.length }, () => 0); | |
| 63 | for (let index = 1; index < sequence.length; index++) { | |
| 64 | let length = lengths[index - 1]!; | |
| 65 | while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!; | |
| 66 | if (sequence[index] === sequence[length]) length++; | |
| 67 | lengths[index] = length; | |
| 68 | } | |
| 69 | return lengths.at(-1) ?? 0; | |
| 70 | } | |
| 71 | ||
| 72 | /** Dashboard usage includes headless agents and reports fresh input separately from cache reads. */ | |
| 73 | export async function readCursorAccountUsage( | |
| 74 | credentialSource: string | { readonly kind: "keychain" }, | |
| 75 | sinceMs: number, | |
| 76 | endDate: number, | |
| 77 | request: (url: string, init: RequestInit) => Promise<Response> = globalThis.fetch, | |
| 78 | keychainToken: () => Promise<string | null> = readMacCursorAccessToken, | |
| 79 | ): Promise<CursorAccountUsageReadResult> { | |
| 80 | let accessToken: unknown; | |
| 81 | try { | |
| 82 | accessToken = | |
| 83 | typeof credentialSource === "string" | |
| 84 | ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken | |
| 85 | : await keychainToken(); | |
| 86 | } catch (cause) { | |
| 87 | const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT"; | |
| 88 | return { | |
| 89 | accountKey: null, | |
| 90 | records: [], | |
| 91 | missing, | |
| 92 | error: missing | |
| 93 | ? null | |
| 94 | : typeof credentialSource === "string" | |
| 95 | ? "Cursor credentials could not be read." | |
| 96 | : cause instanceof CursorKeychainTimeoutError | |
| 97 | ? "Allow Keychain access on the Mac running T3 Code, then refresh." | |
| 98 | : "Cursor Keychain credentials could not be read.", | |
| 99 | }; | |
| 100 | } | |
| 101 | if (typeof accessToken !== "string" || !accessToken) { | |
| 102 | return { | |
| 103 | accountKey: null, | |
| 104 | records: [], | |
| 105 | missing: true, | |
| 106 | error: | |
| 107 | typeof credentialSource === "string" | |
| 108 | ? null | |
| 109 | : "Cursor account history needs a macOS Keychain CLI login on this server.", | |
| 110 | }; | |
| 111 | } | |
| 112 | let accountKey: string | null = null; | |
| 113 | const cancel = new AbortController(); | |
| 114 | try { | |
| 115 | const payload = accessToken.split(".")[1]; | |
| 116 | const subject = object( | |
| 117 | JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), | |
| 118 | ).sub; | |
| 119 | if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication"); | |
| 120 | const userId = subject.split("|").at(-1); | |
| 121 | if (!userId) throw new Error("Invalid authentication"); | |
| 122 | accountKey = accountHash(subject); | |
| 123 | if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) | |
| 124 | throw new Error("Invalid date window"); | |
| 125 | const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]); | |
| 126 | const records: UsageRecord[] = []; | |
| 127 | const occurrences = new Map<string, number>(); | |
| 128 | const pages: unknown[][] = []; | |
| 129 | let completed = false; | |
| 130 | const pageSize = 1000; | |
| 131 | let total: number | undefined; | |
| 132 | const readPage = async (page: number) => { | |
| 133 | // A count can include overlapping page boundaries. Allow room to | |
| 134 | // reconcile them without imposing a fixed account-size limit. | |
| 135 | if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { | |
| 136 | throw new Error("Account usage page limit exceeded"); | |
| 137 | } | |
| 138 | const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { | |
| 139 | method: "POST", | |
| 140 | redirect: "error", | |
| 141 | signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), | |
| 142 | headers: { | |
| 143 | "Content-Type": "application/json", | |
| 144 | Origin: "https://cursor.com", | |
| 145 | Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, | |
| 146 | }, | |
| 147 | body: JSON.stringify({ | |
| 148 | page, | |
| 149 | pageSize, | |
| 150 | startDate: String(sinceMs), | |
| 151 | endDate: String(endDate), | |
| 152 | }), | |
| 153 | }); | |
| 154 | if (response.status === 401 || response.status === 403) { | |
| 155 | return { | |
| 156 | accountKey, | |
| 157 | records: [], | |
| 158 | missing: false, | |
| 159 | error: "Sign in to Cursor again to read account usage.", | |
| 160 | }; | |
| 161 | } | |
| 162 | if (!response.ok) throw new Error("Account usage request failed"); | |
| 163 | const parsed: unknown = await response.json(); | |
| 164 | if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 165 | throw new Error("Invalid account usage page"); | |
| 166 | } | |
| 167 | const body = object(parsed); | |
| 168 | const keys = Object.keys(body); | |
| 169 | if ("error" in body || "message" in body || "code" in body) | |
| 170 | throw new Error("Account usage error response"); | |
| 171 | const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; | |
| 172 | const events = | |
| 173 | keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") | |
| 174 | ? [] | |
| 175 | : body.usageEventsDisplay; | |
| 176 | if ( | |
| 177 | (count !== undefined && | |
| 178 | (typeof count !== "number" || | |
| 179 | !Number.isSafeInteger(count) || | |
| 180 | count < 0 || | |
| 181 | (total !== undefined && count !== total))) || | |
| 182 | !Array.isArray(events) || | |
| 183 | events.length > pageSize || | |
| 184 | (count === undefined && !Array.isArray(body.usageEventsDisplay)) | |
| 185 | ) { | |
| 186 | throw new Error("Inconsistent account usage page"); | |
| 187 | } | |
| 188 | if (typeof count === "number") total = count; | |
| 189 | return events; | |
| 190 | }; | |
| 191 | // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages | |
| 192 | // the first one's count implies are requested up to six ahead and consumed in page order. | |
| 193 | const ahead: ReturnType<typeof readPage>[] = []; | |
| 194 | for (let page = 1; ; page++) { | |
| 195 | while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { | |
| 196 | const pending = readPage(page + ahead.length); | |
| 197 | // Handled when its turn comes, or cancelled if the read ends first. | |
| 198 | pending.catch(() => undefined); | |
| 199 | ahead.push(pending); | |
| 200 | } | |
| 201 | const events = await (ahead.shift() ?? readPage(page)); | |
| 202 | // A rejected login comes back as the finished result instead of a page. | |
| 203 | if (!Array.isArray(events)) return events; | |
| 204 | pages.push(events); | |
| 205 | if (events.length < pageSize) { | |
| 206 | completed = true; | |
| 207 | break; | |
| 208 | } | |
| 209 | } | |
| 210 | if (!completed) throw new Error("Account usage page limit exceeded"); | |
| 211 | const rawCount = pages.reduce((sum, page) => sum + page.length, 0); | |
| 212 | if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages"); | |
| 213 | let removalsRemaining = total === undefined ? 0 : rawCount - total; | |
| 214 | let previousKeys: string[] = []; | |
| 215 | for (const events of pages) { | |
| 216 | const eventKeys = | |
| 217 | removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : []; | |
| 218 | const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); | |
| 219 | removalsRemaining -= removalCount; | |
| 220 | previousKeys = eventKeys; | |
| 221 | for (const raw of events.slice(removalCount)) { | |
| 222 | const event = object(raw); | |
| 223 | const usage = object(event.tokenUsage); | |
| 224 | if (event.tokenUsage === undefined || event.tokenUsage === null) continue; | |
| 225 | for (const key of [ | |
| 226 | "inputTokens", | |
| 227 | "outputTokens", | |
| 228 | "cacheReadTokens", | |
| 229 | "cacheWriteTokens", | |
| 230 | "totalCents", | |
| 231 | ]) { | |
| 232 | const value = usage[key]; | |
| 233 | if ( | |
| 234 | value !== undefined && | |
| 235 | (typeof value !== "number" || !Number.isFinite(value) || value < 0) | |
| 236 | ) { | |
| 237 | throw new Error("Invalid account usage totals"); | |
| 238 | } | |
| 239 | } | |
| 240 | const timestampMs = | |
| 241 | typeof event.timestamp === "string" && event.timestamp.trim() !== "" | |
| 242 | ? Number(event.timestamp) | |
| 243 | : event.timestamp; | |
| 244 | if ( | |
| 245 | typeof timestampMs !== "number" || | |
| 246 | !Number.isFinite(timestampMs) || | |
| 247 | typeof event.model !== "string" || | |
| 248 | !event.model | |
| 249 | ) | |
| 250 | throw new Error("Invalid account usage event"); | |
| 251 | if (timestampMs < sinceMs || timestampMs > endDate) continue; | |
| 252 | const totals = { | |
| 253 | uncachedInputTokens: tokens(usage.inputTokens), | |
| 254 | cachedInputTokens: tokens(usage.cacheReadTokens), | |
| 255 | cacheCreationTokens: tokens(usage.cacheWriteTokens), | |
| 256 | outputTokens: tokens(usage.outputTokens), | |
| 257 | reasoningTokens: 0, | |
| 258 | }; | |
| 259 | const reportedCostUsd = | |
| 260 | typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; | |
| 261 | const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; | |
| 262 | // No event ID is provided. Preserve identical billed rows with an occurrence index. | |
| 263 | const key = accountHash( | |
| 264 | JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]), | |
| 265 | ); | |
| 266 | const occurrence = occurrences.get(key) ?? 0; | |
| 267 | occurrences.set(key, occurrence + 1); | |
| 268 | records.push({ | |
| 269 | provider: "cursor", | |
| 270 | timestampMs, | |
| 271 | model: event.model, | |
| 272 | rateModel: cursorRateModel(event.model), | |
| 273 | sessionId, | |
| 274 | totals, | |
| 275 | reportedCostUsd, | |
| 276 | speed: "standard", | |
| 277 | dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, | |
| 278 | }); | |
| 279 | } | |
| 280 | await NodeTimersPromises.setImmediate(); | |
| 281 | } | |
| 282 | if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries"); | |
| 283 | return { accountKey, records, missing: false, error: null }; | |
| 284 | } catch { | |
| 285 | return { | |
| 286 | accountKey, | |
| 287 | records: [], | |
| 288 | missing: false, | |
| 289 | error: "Cursor account usage could not be read.", | |
| 290 | }; | |
| 291 | } finally { | |
| 292 | // An early exit leaves the pages requested ahead of it in flight. | |
| 293 | cancel.abort(); | |
| 294 | } | |
| 295 | } | |
| 296 | ||
| 297 | /** Reads one range of Cursor account usage. A service so tests can stand in for Cursor's API. */ | |
| 298 | export class CursorAccountReader extends Context.Service< | |
| 299 | CursorAccountReader, | |
| 300 | { | |
| 301 | readonly read: ( | |
| 302 | credentialSource: string | { readonly kind: "keychain" }, | |
| 303 | sinceMs: number, | |
| 304 | untilMs: number, | |
| 305 | ) => Effect.Effect<CursorAccountUsageReadResult>; | |
| 306 | } | |
| 307 | >()("t3/usage/cursorUsageReader/CursorAccountReader") {} | |
| 308 | ||
| 309 | /** Reads Cursor's dashboard API with the saved CLI or Keychain login. */ | |
| 310 | export const layer = Layer.succeed( | |
| 311 | CursorAccountReader, | |
| 312 | CursorAccountReader.of({ | |
| 313 | read: (credentialSource, sinceMs, untilMs) => | |
| 314 | Effect.promise(() => readCursorAccountUsage(credentialSource, sinceMs, untilMs)), | |
| 315 | }), | |
| 316 | ); |
apps/server/src/usage/usagePricing.test.ts+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | import { cursorRateModel } from "./cursorUsageReader.ts"; | |
| 3 | import { cursorRateModel } from "@t3tools/provider-cursor/server/accountUsage"; | |
| 4 | 4 | import type { UsageSpeed } from "@t3tools/provider-core/server/usage"; |
| 5 | 5 | import { |
| 6 | 6 | cacheSavingsUsd, |
apps/server/src/usage/usageTranscriptReader.test.ts-230| ... | ... | @@ -10,7 +10,6 @@ import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; |
| 10 | 10 | |
| 11 | 11 | import { TEST_FORMATS } from "./usageTestFormats.ts"; |
| 12 | 12 | import { readTranscriptRecords } from "./usageTranscriptReader.ts"; |
| 13 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; | |
| 14 | 13 | import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; |
| 15 | 14 | |
| 16 | 15 | let dir: string; |
| ... | ... | @@ -252,235 +251,6 @@ describe("readTranscriptRecords resume", () => { |
| 252 | 251 | }); |
| 253 | 252 | |
| 254 | 253 | describe("SQLite usage readers", () => { |
| 255 | it("reads Cursor account history with the default macOS Keychain login", async () => { | |
| 256 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 257 | let keychainReads = 0; | |
| 258 | const result = await readCursorAccountUsage( | |
| 259 | { kind: "keychain" }, | |
| 260 | 0, | |
| 261 | 1781000000000, | |
| 262 | async (_url, init) => { | |
| 263 | assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); | |
| 264 | return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); | |
| 265 | }, | |
| 266 | async () => { | |
| 267 | keychainReads++; | |
| 268 | return accessToken; | |
| 269 | }, | |
| 270 | ); | |
| 271 | assert.strictEqual(keychainReads, 1); | |
| 272 | assert.isNull(result.error); | |
| 273 | assert.isFalse(result.missing); | |
| 274 | assert.isNotNull(result.accountKey); | |
| 275 | }); | |
| 276 | ||
| 277 | it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { | |
| 278 | const authPath = NodePath.join(dir, "auth.json"); | |
| 279 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; | |
| 280 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 281 | const pages: number[] = []; | |
| 282 | const signals: AbortSignal[] = []; | |
| 283 | const request = async (url: string, init: RequestInit) => { | |
| 284 | assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); | |
| 285 | assert.strictEqual(init?.redirect, "error"); | |
| 286 | const headers = new Headers(init?.headers); | |
| 287 | assert.strictEqual(headers.get("origin"), "https://cursor.com"); | |
| 288 | assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); | |
| 289 | const body = JSON.parse(String(init?.body)); | |
| 290 | pages.push(body.page); | |
| 291 | if (init.signal) signals.push(init.signal); | |
| 292 | return Response.json({ | |
| 293 | totalUsageEventsCount: 1001, | |
| 294 | usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ | |
| 295 | timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), | |
| 296 | model: "claude-sonnet-4-5", | |
| 297 | conversationId: `conversation-${body.page}`, | |
| 298 | isHeadless: body.page === 2, | |
| 299 | chargedCents: 0, | |
| 300 | tokenUsage: { | |
| 301 | inputTokens: 10, | |
| 302 | outputTokens: 5, | |
| 303 | cacheReadTokens: 30, | |
| 304 | cacheWriteTokens: 2, | |
| 305 | totalCents: 25, | |
| 306 | }, | |
| 307 | })), | |
| 308 | }); | |
| 309 | }; | |
| 310 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); | |
| 311 | assert.isNull(result.error); | |
| 312 | assert.deepStrictEqual(pages, [1, 2]); | |
| 313 | assert.lengthOf(signals, 2); | |
| 314 | assert.notStrictEqual(signals[0], signals[1]); | |
| 315 | assert.strictEqual(result.records.length, 1001); | |
| 316 | assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); | |
| 317 | assert.deepStrictEqual(result.records[0]?.totals, { | |
| 318 | uncachedInputTokens: 10, | |
| 319 | cachedInputTokens: 30, | |
| 320 | cacheCreationTokens: 2, | |
| 321 | outputTokens: 5, | |
| 322 | reasoningTokens: 0, | |
| 323 | }); | |
| 324 | assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); | |
| 325 | assert.isFalse(result.accountKey?.includes("demo") ?? true); | |
| 326 | }); | |
| 327 | ||
| 328 | it("reads Cursor account history beyond 100 pages", async () => { | |
| 329 | const authPath = NodePath.join(dir, "auth.json"); | |
| 330 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 331 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 332 | const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); | |
| 333 | let requests = 0; | |
| 334 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 335 | requests += 1; | |
| 336 | return Response.json({ | |
| 337 | totalUsageEventsCount: 100_001, | |
| 338 | usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], | |
| 339 | }); | |
| 340 | }); | |
| 341 | assert.isNull(result.error); | |
| 342 | assert.strictEqual(requests, 101); | |
| 343 | assert.deepStrictEqual(result.records, []); | |
| 344 | }); | |
| 345 | ||
| 346 | it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { | |
| 347 | const authPath = NodePath.join(dir, "auth.json"); | |
| 348 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 349 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 350 | for (const body of [ | |
| 351 | {}, | |
| 352 | { totalUsageEventsCount: 0 }, | |
| 353 | { totalUsageEventsCount: 0, usageEventsDisplay: [] }, | |
| 354 | ]) { | |
| 355 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => | |
| 356 | Response.json(body), | |
| 357 | ); | |
| 358 | assert.isNull(result.error); | |
| 359 | assert.deepStrictEqual(result.records, []); | |
| 360 | assert.isFalse(result.missing); | |
| 361 | } | |
| 362 | for (const body of [ | |
| 363 | { error: "upstream error" }, | |
| 364 | { detail: "unknown error envelope" }, | |
| 365 | { totalUsageEventsCount: 0, error: "upstream error" }, | |
| 366 | null, | |
| 367 | [], | |
| 368 | "invalid", | |
| 369 | 0, | |
| 370 | ]) { | |
| 371 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => | |
| 372 | Response.json(body), | |
| 373 | ); | |
| 374 | assert.isNotNull(result.error); | |
| 375 | assert.deepStrictEqual(result.records, []); | |
| 376 | } | |
| 377 | }); | |
| 378 | ||
| 379 | it("requires a terminal Cursor page after a full page reaches the reported count", async () => { | |
| 380 | const authPath = NodePath.join(dir, "auth.json"); | |
| 381 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 382 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 383 | let requests = 0; | |
| 384 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 385 | requests++; | |
| 386 | return Response.json( | |
| 387 | requests === 1 | |
| 388 | ? { | |
| 389 | totalUsageEventsCount: 1000, | |
| 390 | usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ | |
| 391 | timestamp: String(1780000000000 + index), | |
| 392 | model: "gpt-5", | |
| 393 | tokenUsage: { inputTokens: 10, outputTokens: 5 }, | |
| 394 | })), | |
| 395 | } | |
| 396 | : { totalUsageEventsCount: 1000 }, | |
| 397 | ); | |
| 398 | }); | |
| 399 | assert.isNull(result.error); | |
| 400 | assert.strictEqual(result.records.length, 1000); | |
| 401 | assert.strictEqual(requests, 2); | |
| 402 | }); | |
| 403 | ||
| 404 | it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { | |
| 405 | const authPath = NodePath.join(dir, "auth.json"); | |
| 406 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 407 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 408 | const event = (index: number) => ({ | |
| 409 | timestamp: String(1780000000000 + index), | |
| 410 | model: "gpt-5", | |
| 411 | tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, | |
| 412 | }); | |
| 413 | for (const total of [2000, 2001]) { | |
| 414 | let requests = 0; | |
| 415 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 416 | requests++; | |
| 417 | return Response.json({ | |
| 418 | totalUsageEventsCount: total, | |
| 419 | usageEventsDisplay: | |
| 420 | requests === 1 | |
| 421 | ? Array.from({ length: 1000 }, (_, index) => event(index)) | |
| 422 | : requests === 2 | |
| 423 | ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) | |
| 424 | : [event(1999)], | |
| 425 | }); | |
| 426 | }); | |
| 427 | assert.isNull(result.error); | |
| 428 | assert.strictEqual(result.records.length, total); | |
| 429 | assert.strictEqual(requests, 3); | |
| 430 | assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); | |
| 431 | assert.strictEqual( | |
| 432 | result.records.filter((record) => record.timestampMs === 1780000000999).length, | |
| 433 | total === 2000 ? 1 : 2, | |
| 434 | ); | |
| 435 | assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); | |
| 436 | } | |
| 437 | let requests = 0; | |
| 438 | const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 439 | requests++; | |
| 440 | return Response.json({ | |
| 441 | totalUsageEventsCount: 1001, | |
| 442 | usageEventsDisplay: | |
| 443 | requests === 1 | |
| 444 | ? Array.from({ length: 1000 }, (_, index) => event(index)) | |
| 445 | : [event(500), event(1000)], | |
| 446 | }); | |
| 447 | }); | |
| 448 | assert.isNotNull(inconsistent.error); | |
| 449 | assert.deepStrictEqual(inconsistent.records, []); | |
| 450 | }); | |
| 451 | ||
| 452 | it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { | |
| 453 | const authPath = NodePath.join(dir, "auth.json"); | |
| 454 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; | |
| 455 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 456 | const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => | |
| 457 | Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), | |
| 458 | ); | |
| 459 | assert.isNotNull(truncated.error); | |
| 460 | assert.deepStrictEqual(truncated.records, []); | |
| 461 | const denied = await readCursorAccountUsage( | |
| 462 | authPath, | |
| 463 | 0, | |
| 464 | 1781000000000, | |
| 465 | async () => new Response(accessToken, { status: 401 }), | |
| 466 | ); | |
| 467 | assert.isNotNull(denied.error); | |
| 468 | assert.isFalse(denied.error?.includes(accessToken) ?? true); | |
| 469 | assert.deepStrictEqual(denied.records, []); | |
| 470 | let requested = false; | |
| 471 | const missing = await readCursorAccountUsage( | |
| 472 | NodePath.join(dir, "missing.json"), | |
| 473 | 0, | |
| 474 | 1781000000000, | |
| 475 | async () => { | |
| 476 | requested = true; | |
| 477 | return Response.json({}); | |
| 478 | }, | |
| 479 | ); | |
| 480 | assert.isTrue(missing.missing); | |
| 481 | assert.isFalse(requested); | |
| 482 | }); | |
| 483 | ||
| 484 | 254 | it("deduplicates Antigravity generation and step usage while preserving retry model and token buckets", async () => { |
| 485 | 255 | const db = new NodeSqlite.DatabaseSync(NodePath.join(dir, "session-1.db")); |
| 486 | 256 | const stamp = protoNumber(1, 1780000000); |
packages/provider-core/src/server/usage.ts+3| ... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 | */ |
| 12 | 12 | import type { |
| 13 | 13 | ProviderInstanceId, |
| 14 | ServerSettings, | |
| 14 | 15 | UsageProviderKind, |
| 15 | 16 | UsageSource, |
| 16 | 17 | UsageTokenTotals, |
| ... | ... | @@ -146,6 +147,8 @@ export interface ProviderUsageScan { |
| 146 | 147 | |
| 147 | 148 | export interface ProviderUsageScanInput<Config> { |
| 148 | 149 | readonly instances: ReadonlyArray<ProviderUsageInstance<Config>>; |
| 150 | /** The settings snapshot the whole read runs against. */ | |
| 151 | readonly settings: ServerSettings; | |
| 149 | 152 | /** Files last written before this cannot hold records inside the window. */ |
| 150 | 153 | readonly windowStartMs: number; |
| 151 | 154 | /** The oldest usage the server keeps; account caches cover back to here. */ |
packages/provider-cursor/package.json+16| ... | ... | @@ -23,6 +23,22 @@ |
| 23 | 23 | "types": "./src/server/CursorAgentSdk.ts", |
| 24 | 24 | "import": "./src/server/CursorAgentSdk.ts" |
| 25 | 25 | }, |
| 26 | "./server/accountUsage": { | |
| 27 | "types": "./src/server/accountUsage.ts", | |
| 28 | "import": "./src/server/accountUsage.ts" | |
| 29 | }, | |
| 30 | "./server/CursorAccountReader": { | |
| 31 | "types": "./src/server/CursorAccountReader.ts", | |
| 32 | "import": "./src/server/CursorAccountReader.ts" | |
| 33 | }, | |
| 34 | "./server/CursorKeychain": { | |
| 35 | "types": "./src/server/CursorKeychain.ts", | |
| 36 | "import": "./src/server/CursorKeychain.ts" | |
| 37 | }, | |
| 38 | "./server/CursorUsageAccounts": { | |
| 39 | "types": "./src/server/CursorUsageAccounts.ts", | |
| 40 | "import": "./src/server/CursorUsageAccounts.ts" | |
| 41 | }, | |
| 26 | 42 | "./server/CursorSdk": { |
| 27 | 43 | "types": "./src/server/CursorSdk.ts", |
| 28 | 44 | "import": "./src/server/CursorSdk.ts" |
packages/provider-cursor/src/server.ts+2-4| ... | ... | @@ -1,10 +1,8 @@ |
| 1 | 1 | /** |
| 2 | * Cursor's server entry: the driver the server registers, its adapter | |
| 3 | * driver, the SDK runner layer the server provides once, and the keychain | |
| 4 | * token reader the usage scanner shares. | |
| 2 | * Cursor's server entry: the driver the server registers and its adapter | |
| 3 | * driver. | |
| 5 | 4 | * |
| 6 | 5 | * @module provider-cursor/server |
| 7 | 6 | */ |
| 8 | 7 | export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts"; |
| 9 | 8 | export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts"; |
| 10 | export { CursorKeychainTimeoutError, readMacCursorAccessToken } from "./server/keychainToken.ts"; |
packages/provider-cursor/src/server/CursorAccountReader.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | /** | |
| 2 | * CursorAccountReader - reads one range of Cursor account usage from its | |
| 3 | * dashboard API. A service so tests can stand in for Cursor's API. | |
| 4 | * | |
| 5 | * @module provider-cursor/server/CursorAccountReader | |
| 6 | */ | |
| 7 | import * as Context from "effect/Context"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Result from "effect/Result"; | |
| 11 | ||
| 12 | import type { CursorCredentialSource } from "./accountCache.ts"; | |
| 13 | import { readCursorAccountUsage, type CursorAccountUsageReadResult } from "./accountUsage.ts"; | |
| 14 | import * as CursorKeychain from "./CursorKeychain.ts"; | |
| 15 | ||
| 16 | export class CursorAccountReader extends Context.Service< | |
| 17 | CursorAccountReader, | |
| 18 | { | |
| 19 | readonly read: ( | |
| 20 | credentialSource: CursorCredentialSource, | |
| 21 | sinceMs: number, | |
| 22 | untilMs: number, | |
| 23 | ) => Effect.Effect<CursorAccountUsageReadResult>; | |
| 24 | } | |
| 25 | >()("@t3tools/provider-cursor/server/CursorAccountReader") {} | |
| 26 | ||
| 27 | /** Reads Cursor's dashboard API with the saved CLI or Keychain login. */ | |
| 28 | export const layer = Layer.effect( | |
| 29 | CursorAccountReader, | |
| 30 | Effect.gen(function* () { | |
| 31 | const keychain = yield* CursorKeychain.CursorKeychain; | |
| 32 | return CursorAccountReader.of({ | |
| 33 | read: (credentialSource, sinceMs, untilMs) => | |
| 34 | Effect.gen(function* () { | |
| 35 | // Only a Keychain login asks the Keychain. The dashboard reader | |
| 36 | // turns its outcome, a failure included, into the source's message. | |
| 37 | const token = | |
| 38 | typeof credentialSource === "string" | |
| 39 | ? Result.succeed(null) | |
| 40 | : yield* Effect.result(keychain.accessToken); | |
| 41 | return yield* Effect.promise(() => | |
| 42 | readCursorAccountUsage(credentialSource, sinceMs, untilMs, globalThis.fetch, () => | |
| 43 | Result.isSuccess(token) | |
| 44 | ? Promise.resolve(token.success) | |
| 45 | : Promise.reject(token.failure), | |
| 46 | ), | |
| 47 | ); | |
| 48 | }), | |
| 49 | }); | |
| 50 | }), | |
| 51 | ); |
packages/provider-cursor/src/server/CursorKeychain.test.ts created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Deferred from "effect/Deferred"; | |
| 3 | import * as Duration from "effect/Duration"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Fiber from "effect/Fiber"; | |
| 6 | import * as TestClock from "effect/testing/TestClock"; | |
| 7 | ||
| 8 | import * as CursorKeychain from "./CursorKeychain.ts"; | |
| 9 | ||
| 10 | describe("CursorKeychain", () => { | |
| 11 | it.effect("shares concurrent reads and rechecks after the cache expires", () => | |
| 12 | Effect.gen(function* () { | |
| 13 | let reads = 0; | |
| 14 | const keychain = CursorKeychain.make( | |
| 15 | Effect.sync(() => { | |
| 16 | reads++; | |
| 17 | return `token-${reads}`; | |
| 18 | }), | |
| 19 | ); | |
| 20 | assert.deepStrictEqual( | |
| 21 | yield* Effect.all([keychain.accessToken, keychain.accessToken], { | |
| 22 | concurrency: "unbounded", | |
| 23 | }), | |
| 24 | ["token-1", "token-1"], | |
| 25 | ); | |
| 26 | assert.strictEqual(yield* keychain.accessToken, "token-1"); | |
| 27 | assert.strictEqual(reads, 1); | |
| 28 | yield* TestClock.adjust(Duration.minutes(5)); | |
| 29 | assert.strictEqual(yield* keychain.accessToken, "token-2"); | |
| 30 | }), | |
| 31 | ); | |
| 32 | ||
| 33 | it.effect("gives up on an unanswered prompt and reuses it on the next read", () => | |
| 34 | Effect.gen(function* () { | |
| 35 | let reads = 0; | |
| 36 | const allow = yield* Deferred.make<string>(); | |
| 37 | const keychain = CursorKeychain.make( | |
| 38 | Effect.suspend(() => { | |
| 39 | reads++; | |
| 40 | return Deferred.await(allow); | |
| 41 | }), | |
| 42 | Duration.millis(1), | |
| 43 | ); | |
| 44 | const timedOut = yield* keychain.accessToken.pipe(Effect.forkChild); | |
| 45 | yield* TestClock.adjust(Duration.millis(1)); | |
| 46 | const error = yield* Fiber.join(timedOut).pipe(Effect.flip); | |
| 47 | assert.isTrue(CursorKeychain.isCursorKeychainTimeoutError(error)); | |
| 48 | const retry = yield* keychain.accessToken.pipe(Effect.forkChild); | |
| 49 | yield* Deferred.succeed(allow, "token"); | |
| 50 | assert.strictEqual(yield* Fiber.join(retry), "token"); | |
| 51 | assert.strictEqual(reads, 1); | |
| 52 | }), | |
| 53 | ); | |
| 54 | }); |
packages/provider-cursor/src/server/CursorKeychain.ts created+118| ... | ... | @@ -0,0 +1,118 @@ |
| 1 | /** | |
| 2 | * CursorKeychain - the Cursor CLI's macOS Keychain login, shared by usage | |
| 3 | * history and usage limits in one server process. | |
| 4 | * | |
| 5 | * macOS shows the access prompt on the server's own screen, which a remote | |
| 6 | * client cannot answer, so a read gives up after a timeout. The Keychain read | |
| 7 | * stays in flight: the next read joins it instead of stacking a second prompt, | |
| 8 | * and picks up the token once someone allows access. | |
| 9 | * | |
| 10 | * @module provider-cursor/server/CursorKeychain | |
| 11 | */ | |
| 12 | import * as NodeModule from "node:module"; | |
| 13 | ||
| 14 | import * as Clock from "effect/Clock"; | |
| 15 | import * as Context from "effect/Context"; | |
| 16 | import * as Deferred from "effect/Deferred"; | |
| 17 | import * as Duration from "effect/Duration"; | |
| 18 | import * as Effect from "effect/Effect"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as Option from "effect/Option"; | |
| 21 | import * as Schema from "effect/Schema"; | |
| 22 | ||
| 23 | const CACHE_MS = 5 * 60_000; | |
| 24 | const DEFAULT_TIMEOUT = Duration.seconds(30); | |
| 25 | ||
| 26 | const requireForKeyring = NodeModule.createRequire(import.meta.url); | |
| 27 | ||
| 28 | /** Nobody answered the macOS Keychain prompt in time. */ | |
| 29 | export class CursorKeychainTimeoutError extends Schema.TaggedError<CursorKeychainTimeoutError>()( | |
| 30 | "CursorKeychainTimeoutError", | |
| 31 | {}, | |
| 32 | ) { | |
| 33 | override get message(): string { | |
| 34 | return "Timed out waiting for Keychain access."; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | export const isCursorKeychainTimeoutError = Schema.is(CursorKeychainTimeoutError); | |
| 39 | ||
| 40 | /** The Keychain could not be read. */ | |
| 41 | export class CursorKeychainReadError extends Schema.TaggedError<CursorKeychainReadError>()( | |
| 42 | "CursorKeychainReadError", | |
| 43 | { cause: Schema.Defect() }, | |
| 44 | ) { | |
| 45 | override get message(): string { | |
| 46 | return "Cursor Keychain credentials could not be read."; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | export class CursorKeychain extends Context.Service< | |
| 51 | CursorKeychain, | |
| 52 | { | |
| 53 | /** The access token, or `null` when the Keychain holds no Cursor login. */ | |
| 54 | readonly accessToken: Effect.Effect< | |
| 55 | string | null, | |
| 56 | CursorKeychainTimeoutError | CursorKeychainReadError | |
| 57 | >; | |
| 58 | } | |
| 59 | >()("@t3tools/provider-cursor/server/CursorKeychain") {} | |
| 60 | ||
| 61 | /** Reads the Cursor CLI's default macOS credential without invoking the shared security binary. */ | |
| 62 | const readKeychainEntry = Effect.tryPromise({ | |
| 63 | try: async () => { | |
| 64 | const { AsyncEntry } = requireForKeyring( | |
| 65 | "@napi-rs/keyring", | |
| 66 | ) as typeof import("@napi-rs/keyring"); | |
| 67 | return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; | |
| 68 | }, | |
| 69 | catch: (cause) => new CursorKeychainReadError({ cause }), | |
| 70 | }); | |
| 71 | ||
| 72 | /** A Keychain backed by `read`, which tests replace. */ | |
| 73 | export const make = ( | |
| 74 | read: Effect.Effect<string | null, CursorKeychainReadError> = readKeychainEntry, | |
| 75 | timeout: Duration.Input = DEFAULT_TIMEOUT, | |
| 76 | ): CursorKeychain["Service"] => { | |
| 77 | let cached: { readonly token: string; readonly untilMs: number } | null = null; | |
| 78 | let pending: Deferred.Deferred<string | null, CursorKeychainReadError> | null = null; | |
| 79 | ||
| 80 | /** Joins the read in flight, or starts one that outlives the caller. */ | |
| 81 | const startRead = Effect.uninterruptible( | |
| 82 | Effect.gen(function* () { | |
| 83 | if (pending !== null) return pending; | |
| 84 | const done = Deferred.makeUnsafe<string | null, CursorKeychainReadError>(); | |
| 85 | pending = done; | |
| 86 | yield* read.pipe( | |
| 87 | Effect.tap((token) => | |
| 88 | Clock.currentTimeMillis.pipe( | |
| 89 | Effect.map((nowMs) => { | |
| 90 | cached = token ? { token, untilMs: nowMs + CACHE_MS } : null; | |
| 91 | }), | |
| 92 | ), | |
| 93 | ), | |
| 94 | Effect.onExit((exit) => | |
| 95 | Effect.suspend(() => { | |
| 96 | pending = null; | |
| 97 | return Deferred.done(done, exit); | |
| 98 | }), | |
| 99 | ), | |
| 100 | Effect.forkDetach, | |
| 101 | ); | |
| 102 | return done; | |
| 103 | }), | |
| 104 | ); | |
| 105 | ||
| 106 | const accessToken = Effect.gen(function* () { | |
| 107 | const nowMs = yield* Clock.currentTimeMillis; | |
| 108 | if (cached !== null && cached.untilMs > nowMs) return cached.token; | |
| 109 | const done = yield* startRead; | |
| 110 | const token = yield* Deferred.await(done).pipe(Effect.timeoutOption(timeout)); | |
| 111 | if (Option.isNone(token)) return yield* new CursorKeychainTimeoutError(); | |
| 112 | return token.value; | |
| 113 | }); | |
| 114 | ||
| 115 | return CursorKeychain.of({ accessToken }); | |
| 116 | }; | |
| 117 | ||
| 118 | export const layer = Layer.sync(CursorKeychain, () => make()); |
packages/provider-cursor/src/server/CursorUsageAccounts.ts created+342| ... | ... | @@ -0,0 +1,342 @@ |
| 1 | /** | |
| 2 | * CursorUsageAccounts - Cursor's account usage as a usage source, answered | |
| 3 | * from a cache. | |
| 4 | * | |
| 5 | * Cursor's account API is slow, so a read answers from the cached account | |
| 6 | * history and marks itself `refreshing` while a background refresh runs; | |
| 7 | * `awaitRefresh` waits for that refresh instead. The caches persist in the | |
| 8 | * server's state directory, so a restart only refetches the newest edge. See | |
| 9 | * `accountCache`. | |
| 10 | * | |
| 11 | * @module provider-cursor/server/CursorUsageAccounts | |
| 12 | */ | |
| 13 | import * as NodeOS from "node:os"; | |
| 14 | ||
| 15 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 16 | import type { ProviderUsageScan } from "@t3tools/provider-core/server/usage"; | |
| 17 | import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; | |
| 18 | import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 19 | import * as Clock from "effect/Clock"; | |
| 20 | import * as Context from "effect/Context"; | |
| 21 | import * as Deferred from "effect/Deferred"; | |
| 22 | import * as Effect from "effect/Effect"; | |
| 23 | import * as Fiber from "effect/Fiber"; | |
| 24 | import * as FileSystem from "effect/FileSystem"; | |
| 25 | import * as Layer from "effect/Layer"; | |
| 26 | import * as Path from "effect/Path"; | |
| 27 | import * as Schema from "effect/Schema"; | |
| 28 | import * as Semaphore from "effect/Semaphore"; | |
| 29 | ||
| 30 | import { | |
| 31 | CURSOR_ACCOUNT_CACHE_FILE_NAME, | |
| 32 | CURSOR_ACCOUNT_TTL_MS, | |
| 33 | cursorFetchRange, | |
| 34 | decodeCursorAccountCaches, | |
| 35 | encodeCursorAccountCaches, | |
| 36 | isCursorCacheFresh, | |
| 37 | mergeCursorFetch, | |
| 38 | type CursorAccountCache, | |
| 39 | type CursorCredentialSource, | |
| 40 | } from "./accountCache.ts"; | |
| 41 | import * as CursorAccountReader from "./CursorAccountReader.ts"; | |
| 42 | ||
| 43 | const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read."; | |
| 44 | ||
| 45 | /** The cache file is narrowed by hand in `accountCache`, so JSON is enough here. */ | |
| 46 | const decodeCacheFile = Schema.decodeUnknownEffect( | |
| 47 | Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>), | |
| 48 | ); | |
| 49 | ||
| 50 | export class CursorUsageAccounts extends Context.Service< | |
| 51 | CursorUsageAccounts, | |
| 52 | { | |
| 53 | /** This environment's Cursor account usage source, if it has one to report. */ | |
| 54 | readonly scan: (input: { | |
| 55 | /** From the same settings snapshot as the rest of the usage read. */ | |
| 56 | readonly keychainUsageEnabled: boolean; | |
| 57 | readonly windowStartMs: number; | |
| 58 | readonly retentionCutoffMs: number; | |
| 59 | readonly awaitRefresh: boolean; | |
| 60 | }) => Effect.Effect<ReadonlyArray<ProviderUsageScan>>; | |
| 61 | /** Waits for every cache write scheduled so far, as a restart would need. */ | |
| 62 | readonly awaitPersisted: Effect.Effect<void>; | |
| 63 | } | |
| 64 | >()("@t3tools/provider-cursor/server/CursorUsageAccounts") {} | |
| 65 | ||
| 66 | const make = Effect.gen(function* () { | |
| 67 | const fileSystem = yield* FileSystem.FileSystem; | |
| 68 | const path = yield* Path.Path; | |
| 69 | const host = yield* ProviderHost.ProviderHost; | |
| 70 | const accountReader = yield* CursorAccountReader.CursorAccountReader; | |
| 71 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 72 | const platform = yield* HostProcessPlatform; | |
| 73 | ||
| 74 | const cachePath = path.join(host.paths.stateDir, CURSOR_ACCOUNT_CACHE_FILE_NAME); | |
| 75 | /** Account caches by credential source. */ | |
| 76 | const caches = new Map<string, CursorAccountCache>(); | |
| 77 | let cacheDirty = false; | |
| 78 | /** | |
| 79 | * The last failed refresh per credential source, standing for a TTL so a | |
| 80 | * client refetching a broken login does not refetch Cursor each time. A | |
| 81 | * `null` error means there is no login: no source to report. | |
| 82 | */ | |
| 83 | const failures = new Map<string, { readonly atMs: number; readonly error: string | null }>(); | |
| 84 | /** The refresh in flight per credential source, which every read joins. */ | |
| 85 | const refreshes = new Map<string, Deferred.Deferred<void>>(); | |
| 86 | ||
| 87 | /** Loads the persisted caches once; concurrent first readers await the same load. */ | |
| 88 | const ensureLoaded = yield* Effect.cached( | |
| 89 | fileSystem.readFileString(cachePath).pipe( | |
| 90 | Effect.flatMap((raw) => decodeCacheFile(raw)), | |
| 91 | Effect.catchCause(() => Effect.succeed(null)), | |
| 92 | Effect.map((document) => { | |
| 93 | for (const [key, cache] of decodeCursorAccountCaches(document)) caches.set(key, cache); | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | ||
| 98 | // Serialized so an older snapshot never lands after a newer one. The dirty | |
| 99 | // flag is cleared before encoding, so a change while the write is in flight | |
| 100 | // marks it dirty again; a failed write restores it so the next persist retries. | |
| 101 | const persistLock = yield* Semaphore.make(1); | |
| 102 | const persist = Effect.gen(function* () { | |
| 103 | if (!cacheDirty) return; | |
| 104 | cacheDirty = false; | |
| 105 | yield* Effect.sync(() => encodeCursorAccountCaches(caches)).pipe( | |
| 106 | Effect.flatMap((contents) => writeFileStringAtomically({ filePath: cachePath, contents })), | |
| 107 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 108 | Effect.provideService(Path.Path, path), | |
| 109 | Effect.catchCause(() => | |
| 110 | Effect.sync(() => { | |
| 111 | cacheDirty = true; | |
| 112 | }), | |
| 113 | ), | |
| 114 | ); | |
| 115 | }).pipe(persistLock.withPermit, Effect.withSpan("CursorUsageAccounts.persist")); | |
| 116 | ||
| 117 | const pendingPersists = new Set<Fiber.Fiber<void>>(); | |
| 118 | /** Writes the caches in the background, after the read that dirtied them answers. */ | |
| 119 | const schedulePersist = Effect.forkDetach(persist).pipe( | |
| 120 | Effect.map((fiber) => { | |
| 121 | pendingPersists.add(fiber); | |
| 122 | fiber.addObserver(() => pendingPersists.delete(fiber)); | |
| 123 | }), | |
| 124 | ); | |
| 125 | const awaitPersisted = Effect.suspend(() => Fiber.awaitAll([...pendingPersists])).pipe( | |
| 126 | Effect.asVoid, | |
| 127 | ); | |
| 128 | // A write still running at shutdown finishes first, so the next start keeps it. | |
| 129 | yield* Effect.addFinalizer(() => awaitPersisted); | |
| 130 | ||
| 131 | /** Fetches what one account cache is missing, then persists it if anything changed. */ | |
| 132 | const refreshAccount = Effect.fn("CursorUsageAccounts.refreshAccount")(function* ( | |
| 133 | credential: CursorCredentialSource, | |
| 134 | credentialKey: string, | |
| 135 | retentionStartMs: number, | |
| 136 | ) { | |
| 137 | const nowMs = yield* Clock.currentTimeMillis; | |
| 138 | const fetchMissing = (cache: CursorAccountCache | undefined) => { | |
| 139 | const range = cursorFetchRange(cache, retentionStartMs, nowMs); | |
| 140 | return accountReader | |
| 141 | .read(credential, range.sinceMs, range.untilMs) | |
| 142 | .pipe(Effect.map((result) => ({ range, result }))); | |
| 143 | }; | |
| 144 | let base = caches.get(credentialKey); | |
| 145 | let fetched = yield* fetchMissing(base); | |
| 146 | // Another login's history replaces the cached account's, even if reading it fails. | |
| 147 | if ( | |
| 148 | base !== undefined && | |
| 149 | fetched.result.accountKey !== null && | |
| 150 | fetched.result.accountKey !== base.accountKey | |
| 151 | ) { | |
| 152 | caches.delete(credentialKey); | |
| 153 | cacheDirty = true; | |
| 154 | base = undefined; | |
| 155 | fetched = yield* fetchMissing(base); | |
| 156 | } | |
| 157 | const { range, result } = fetched; | |
| 158 | if (result.missing || result.error !== null || result.accountKey === null) { | |
| 159 | failures.set(credentialKey, { | |
| 160 | atMs: nowMs, | |
| 161 | error: result.missing || result.error !== null ? result.error : CURSOR_ACCOUNT_READ_ERROR, | |
| 162 | }); | |
| 163 | yield* schedulePersist; | |
| 164 | return; | |
| 165 | } | |
| 166 | const merged = mergeCursorFetch( | |
| 167 | base, | |
| 168 | result.accountKey, | |
| 169 | range, | |
| 170 | result.records, | |
| 171 | nowMs, | |
| 172 | retentionStartMs, | |
| 173 | ); | |
| 174 | caches.set(credentialKey, merged.cache); | |
| 175 | failures.delete(credentialKey); | |
| 176 | // An unchanged edge is not worth rewriting the file for: after a restart | |
| 177 | // the cache just refetches a slightly wider edge. Persisting anyway retries | |
| 178 | // an earlier failed write; it is a no-op when nothing is dirty. | |
| 179 | if (merged.changed) cacheDirty = true; | |
| 180 | yield* schedulePersist; | |
| 181 | }); | |
| 182 | ||
| 183 | /** Joins the refresh in flight, or starts one. */ | |
| 184 | const startRefresh = ( | |
| 185 | credential: CursorCredentialSource, | |
| 186 | credentialKey: string, | |
| 187 | retentionStartMs: number, | |
| 188 | ) => | |
| 189 | // Enrollment and fork are atomic, so an interrupted caller cannot leave a | |
| 190 | // registered refresh that nothing will finish. | |
| 191 | Effect.uninterruptible( | |
| 192 | Effect.gen(function* () { | |
| 193 | const current = refreshes.get(credentialKey); | |
| 194 | if (current !== undefined) return current; | |
| 195 | const done = Deferred.makeUnsafe<void>(); | |
| 196 | refreshes.set(credentialKey, done); | |
| 197 | // Detached: a departing client must not cancel a fetch later reads reuse. | |
| 198 | yield* refreshAccount(credential, credentialKey, retentionStartMs).pipe( | |
| 199 | Effect.catchCause(() => | |
| 200 | Clock.currentTimeMillis.pipe( | |
| 201 | Effect.map((atMs) => | |
| 202 | failures.set(credentialKey, { atMs, error: CURSOR_ACCOUNT_READ_ERROR }), | |
| 203 | ), | |
| 204 | ), | |
| 205 | ), | |
| 206 | Effect.ensuring( | |
| 207 | Effect.suspend(() => { | |
| 208 | refreshes.delete(credentialKey); | |
| 209 | return Deferred.succeed(done, undefined); | |
| 210 | }), | |
| 211 | ), | |
| 212 | Effect.forkDetach, | |
| 213 | ); | |
| 214 | return done; | |
| 215 | }), | |
| 216 | ); | |
| 217 | ||
| 218 | /** | |
| 219 | * The account source. A cache inside its TTL, or a refresh that failed | |
| 220 | * inside it, answers directly. Otherwise a refresh starts: `awaitRefresh` | |
| 221 | * waits for it, and anything else answers from the cache marked `refreshing`. | |
| 222 | */ | |
| 223 | const accountSource = Effect.fn("CursorUsageAccounts.accountSource")(function* ( | |
| 224 | credential: CursorCredentialSource, | |
| 225 | authPath: string, | |
| 226 | windowStartMs: number, | |
| 227 | retentionStartMs: number, | |
| 228 | awaitRefresh: boolean, | |
| 229 | ) { | |
| 230 | // No saved login means there is no account source to report, not a setup error. | |
| 231 | if ( | |
| 232 | typeof credential === "string" && | |
| 233 | !(yield* fileSystem.exists(credential).pipe(Effect.orElseSucceed(() => true))) | |
| 234 | ) { | |
| 235 | return null; | |
| 236 | } | |
| 237 | yield* ensureLoaded; | |
| 238 | const credentialKey = typeof credential === "string" ? credential : "keychain"; | |
| 239 | const nowMs = yield* Clock.currentTimeMillis; | |
| 240 | const recentFailure = failures.get(credentialKey); | |
| 241 | let refreshing = false; | |
| 242 | if ( | |
| 243 | (recentFailure === undefined || nowMs - recentFailure.atMs >= CURSOR_ACCOUNT_TTL_MS) && | |
| 244 | !isCursorCacheFresh(caches.get(credentialKey), nowMs) | |
| 245 | ) { | |
| 246 | const refresh = yield* startRefresh(credential, credentialKey, retentionStartMs); | |
| 247 | if (awaitRefresh) yield* Deferred.await(refresh); | |
| 248 | else refreshing = true; | |
| 249 | } | |
| 250 | ||
| 251 | const cache = caches.get(credentialKey); | |
| 252 | const failure = refreshing ? undefined : failures.get(credentialKey); | |
| 253 | const failureMessage = failure === undefined ? undefined : failure.error; | |
| 254 | if (failureMessage === null) return null; | |
| 255 | if (cache === undefined) { | |
| 256 | return { | |
| 257 | dir: authPath, | |
| 258 | // Never combine a local fallback with another server's account-wide history. | |
| 259 | ...(refreshing | |
| 260 | ? { files: [], refreshing: true } | |
| 261 | : { files: null, message: failureMessage ?? CURSOR_ACCOUNT_READ_ERROR }), | |
| 262 | } satisfies ProviderUsageScan; | |
| 263 | } | |
| 264 | // The same account includes CLI and desktop history from every machine. | |
| 265 | // A stable remote fingerprint prevents connected environments counting it twice. | |
| 266 | const source = `cursor-account:${cache.accountKey}`; | |
| 267 | return { | |
| 268 | dir: source, | |
| 269 | hostId: "cursor.com", | |
| 270 | volumeId: cache.accountKey, | |
| 271 | files: [ | |
| 272 | { | |
| 273 | path: source, | |
| 274 | records: cache.records.filter((record) => record.timestampMs >= windowStartMs), | |
| 275 | }, | |
| 276 | ], | |
| 277 | ...(failureMessage === undefined | |
| 278 | ? { status: "ok" } | |
| 279 | : { status: "partial", message: failureMessage }), | |
| 280 | ...(refreshing ? { refreshing: true } : {}), | |
| 281 | } satisfies ProviderUsageScan; | |
| 282 | }); | |
| 283 | ||
| 284 | const scan: CursorUsageAccounts["Service"]["scan"] = Effect.fn("CursorUsageAccounts.scan")( | |
| 285 | function* ({ keychainUsageEnabled, windowStartMs, retentionCutoffMs, awaitRefresh }) { | |
| 286 | const home = NodeOS.homedir(); | |
| 287 | const userHome = | |
| 288 | (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; | |
| 289 | const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); | |
| 290 | const cursorHome = | |
| 291 | platform === "darwin" | |
| 292 | ? path.join(userHome, "Library", "Application Support") | |
| 293 | : platform === "win32" | |
| 294 | ? hostEnvironment["APPDATA"] || path.join(userHome, "AppData", "Roaming") | |
| 295 | : configHome && path.isAbsolute(configHome) | |
| 296 | ? configHome | |
| 297 | : path.join(userHome, ".config"); | |
| 298 | const authPath = | |
| 299 | platform === "darwin" | |
| 300 | ? path.join(userHome, ".cursor", "auth.json") | |
| 301 | : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); | |
| 302 | const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; | |
| 303 | const loginUnavailable = | |
| 304 | Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || | |
| 305 | Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || | |
| 306 | credentialStore === "memory"; | |
| 307 | const useKeychain = platform === "darwin" && credentialStore !== "file"; | |
| 308 | if (useKeychain && !loginUnavailable && !keychainUsageEnabled) { | |
| 309 | return [ | |
| 310 | { | |
| 311 | dir: authPath, | |
| 312 | volumeId: "", | |
| 313 | files: null, | |
| 314 | message: "Cursor account usage is off on this environment.", | |
| 315 | action: "enableCursorKeychain", | |
| 316 | }, | |
| 317 | ]; | |
| 318 | } | |
| 319 | if (loginUnavailable) { | |
| 320 | return [ | |
| 321 | { | |
| 322 | dir: authPath, | |
| 323 | files: null, | |
| 324 | message: "Cursor account history needs a Cursor CLI login on this server.", | |
| 325 | }, | |
| 326 | ]; | |
| 327 | } | |
| 328 | const source = yield* accountSource( | |
| 329 | useKeychain ? { kind: "keychain" } : authPath, | |
| 330 | authPath, | |
| 331 | windowStartMs, | |
| 332 | retentionCutoffMs, | |
| 333 | awaitRefresh, | |
| 334 | ); | |
| 335 | return source === null ? [] : [source]; | |
| 336 | }, | |
| 337 | ); | |
| 338 | ||
| 339 | return CursorUsageAccounts.of({ scan, awaitPersisted }); | |
| 340 | }); | |
| 341 | ||
| 342 | export const layer = Layer.effect(CursorUsageAccounts, make); |
packages/provider-cursor/src/server/accountCache.test.ts created+80| ... | ... | @@ -0,0 +1,80 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { cursorFetchRange, mergeCursorFetch } from "./accountCache.ts"; | |
| 4 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 5 | ||
| 6 | const DAY_MS = 24 * 60 * 60 * 1000; | |
| 7 | ||
| 8 | const record = (timestampMs: number): UsageRecord => ({ | |
| 9 | provider: "cursor", | |
| 10 | timestampMs, | |
| 11 | model: "claude-fable-5", | |
| 12 | sessionId: "conversation-1", | |
| 13 | totals: { | |
| 14 | uncachedInputTokens: 0, | |
| 15 | cachedInputTokens: 0, | |
| 16 | cacheCreationTokens: 0, | |
| 17 | outputTokens: 1, | |
| 18 | reasoningTokens: 0, | |
| 19 | }, | |
| 20 | reportedCostUsd: null, | |
| 21 | speed: "standard", | |
| 22 | dedupeKey: `cursor-account:a:${timestampMs}:0`, | |
| 23 | }); | |
| 24 | ||
| 25 | describe("cursorAccountCache", () => { | |
| 26 | it("refetches everything once the cache no longer reaches the retention start", () => { | |
| 27 | const cache = { | |
| 28 | accountKey: "a", | |
| 29 | sinceMs: 0, | |
| 30 | untilMs: 10 * DAY_MS, | |
| 31 | fetchedAtMs: 10 * DAY_MS, | |
| 32 | records: [record(5 * DAY_MS)], | |
| 33 | }; | |
| 34 | const nowMs = 100 * DAY_MS; | |
| 35 | const range = cursorFetchRange(cache, 10 * DAY_MS, nowMs); | |
| 36 | assert.deepStrictEqual(range, { sinceMs: 10 * DAY_MS, untilMs: nowMs }); | |
| 37 | ||
| 38 | const { cache: merged } = mergeCursorFetch( | |
| 39 | cache, | |
| 40 | "a", | |
| 41 | range, | |
| 42 | [record(50 * DAY_MS)], | |
| 43 | nowMs, | |
| 44 | 10 * DAY_MS, | |
| 45 | ); | |
| 46 | assert.deepStrictEqual( | |
| 47 | merged.records.map((entry) => entry.timestampMs), | |
| 48 | [50 * DAY_MS], | |
| 49 | ); | |
| 50 | // Then only the newest edge. | |
| 51 | assert.deepStrictEqual(cursorFetchRange(merged, 11 * DAY_MS, nowMs + DAY_MS), { | |
| 52 | sinceMs: nowMs - 60 * 60 * 1000, | |
| 53 | untilMs: nowMs + DAY_MS, | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | it("reports an edge that only confirms the cache as unchanged", () => { | |
| 58 | const cache = { | |
| 59 | accountKey: "a", | |
| 60 | sinceMs: 0, | |
| 61 | untilMs: 10 * DAY_MS, | |
| 62 | fetchedAtMs: 10 * DAY_MS, | |
| 63 | records: [record(DAY_MS), record(10 * DAY_MS)], | |
| 64 | }; | |
| 65 | const range = cursorFetchRange(cache, 0, 11 * DAY_MS); | |
| 66 | assert.isFalse( | |
| 67 | mergeCursorFetch(cache, "a", range, [record(10 * DAY_MS)], 11 * DAY_MS, 0).changed, | |
| 68 | ); | |
| 69 | assert.isTrue( | |
| 70 | mergeCursorFetch( | |
| 71 | cache, | |
| 72 | "a", | |
| 73 | range, | |
| 74 | [record(10 * DAY_MS), record(10.5 * DAY_MS)], | |
| 75 | 11 * DAY_MS, | |
| 76 | 0, | |
| 77 | ).changed, | |
| 78 | ); | |
| 79 | }); | |
| 80 | }); |
packages/provider-cursor/src/server/accountCache.ts created+271| ... | ... | @@ -0,0 +1,271 @@ |
| 1 | /** | |
| 2 | * Cursor account usage, cached per credential source. | |
| 3 | * | |
| 4 | * Cursor's dashboard API takes about 20 seconds for a 30-day window, so fetched | |
| 5 | * records are kept for the whole retention period, whatever window was asked | |
| 6 | * for. After one full fetch, a refresh fetches only the newest edge. | |
| 7 | * | |
| 8 | * A record's dedupe key carries its timestamp and an occurrence index counted | |
| 9 | * within one fetch. A refetched range therefore replaces every cached record in | |
| 10 | * that range rather than adding to it, so each timestamp's records always come | |
| 11 | * from a single fetch and their occurrence indexes cannot collide. | |
| 12 | * | |
| 13 | * @module provider-cursor/server/accountCache | |
| 14 | */ | |
| 15 | import { cursorRateModel } from "./accountUsage.ts"; | |
| 16 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 17 | ||
| 18 | export type CursorCredentialSource = string | { readonly kind: "keychain" }; | |
| 19 | ||
| 20 | /** Matches the clients' stale time, so a page refetching on focus reuses one fetch. */ | |
| 21 | export const CURSOR_ACCOUNT_TTL_MS = 60 * 1000; | |
| 22 | ||
| 23 | /** Dashboard events can be finalized after they were first read. */ | |
| 24 | const REFETCH_OVERLAP_MS = 60 * 60 * 1000; | |
| 25 | ||
| 26 | export interface CursorAccountCache { | |
| 27 | readonly accountKey: string; | |
| 28 | /** Inclusive range of event timestamps the records are complete for. */ | |
| 29 | readonly sinceMs: number; | |
| 30 | readonly untilMs: number; | |
| 31 | /** When the newest edge was fetched. */ | |
| 32 | readonly fetchedAtMs: number; | |
| 33 | readonly records: readonly UsageRecord[]; | |
| 34 | } | |
| 35 | ||
| 36 | interface CursorFetchRange { | |
| 37 | readonly sinceMs: number; | |
| 38 | readonly untilMs: number; | |
| 39 | } | |
| 40 | ||
| 41 | /** Whether the cache answers without a refresh. */ | |
| 42 | export function isCursorCacheFresh( | |
| 43 | cache: CursorAccountCache | undefined, | |
| 44 | nowMs: number, | |
| 45 | ): cache is CursorAccountCache { | |
| 46 | return cache !== undefined && nowMs - cache.fetchedAtMs < CURSOR_ACCOUNT_TTL_MS; | |
| 47 | } | |
| 48 | ||
| 49 | /** | |
| 50 | * The range to fetch so the cache covers `[retentionStartMs, nowMs]`: the | |
| 51 | * newest edge, or everything when the cache does not reach back that far. | |
| 52 | */ | |
| 53 | export function cursorFetchRange( | |
| 54 | cache: CursorAccountCache | undefined, | |
| 55 | retentionStartMs: number, | |
| 56 | nowMs: number, | |
| 57 | ): CursorFetchRange { | |
| 58 | return cache === undefined || | |
| 59 | cache.sinceMs > retentionStartMs || | |
| 60 | cache.untilMs - REFETCH_OVERLAP_MS < retentionStartMs | |
| 61 | ? { sinceMs: retentionStartMs, untilMs: nowMs } | |
| 62 | : { sinceMs: cache.untilMs - REFETCH_OVERLAP_MS, untilMs: nowMs }; | |
| 63 | } | |
| 64 | ||
| 65 | /** | |
| 66 | * Applies a fetched range to the cache, replacing the cached records it covers | |
| 67 | * and dropping those before the retention start. Pass `undefined` for a cache | |
| 68 | * that belongs to another account. `changed` is false when the fetch only | |
| 69 | * confirmed the cached records, so there is nothing new to persist. | |
| 70 | */ | |
| 71 | export function mergeCursorFetch( | |
| 72 | cache: CursorAccountCache | undefined, | |
| 73 | accountKey: string, | |
| 74 | range: CursorFetchRange, | |
| 75 | fetched: readonly UsageRecord[], | |
| 76 | nowMs: number, | |
| 77 | retentionStartMs: number, | |
| 78 | ) { | |
| 79 | const inRange = (record: UsageRecord) => | |
| 80 | record.timestampMs >= range.sinceMs && record.timestampMs <= range.untilMs; | |
| 81 | const previous = cache?.records ?? []; | |
| 82 | const added = fetched.filter(inRange); | |
| 83 | const replacedKeys = new Set(previous.filter(inRange).map((record) => record.dedupeKey)); | |
| 84 | const changed = | |
| 85 | cache === undefined || | |
| 86 | replacedKeys.size !== added.length || | |
| 87 | added.some((record) => !replacedKeys.has(record.dedupeKey)); | |
| 88 | return { | |
| 89 | changed, | |
| 90 | cache: { | |
| 91 | accountKey, | |
| 92 | sinceMs: retentionStartMs, | |
| 93 | untilMs: range.untilMs, | |
| 94 | fetchedAtMs: nowMs, | |
| 95 | records: [ | |
| 96 | ...previous.filter((record) => !inRange(record) && record.timestampMs >= retentionStartMs), | |
| 97 | ...added, | |
| 98 | ], | |
| 99 | } satisfies CursorAccountCache, | |
| 100 | }; | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Each version writes its own file, as the scan cache does, so servers of | |
| 105 | * different versions sharing a state directory keep their own caches. | |
| 106 | */ | |
| 107 | export const CURSOR_ACCOUNT_CACHE_FILE_NAME = "usage-cursor-account-cache-v1.json"; | |
| 108 | const CURSOR_ACCOUNT_CACHE_VERSION = 1; | |
| 109 | ||
| 110 | /** Positional rows with interned strings, as in the scan cache. */ | |
| 111 | type SerializedRecord = readonly [ | |
| 112 | timestampMs: number, | |
| 113 | modelIndex: number, | |
| 114 | sessionIndex: number, | |
| 115 | uncachedInputTokens: number, | |
| 116 | cachedInputTokens: number, | |
| 117 | cacheCreationTokens: number, | |
| 118 | outputTokens: number, | |
| 119 | reportedCostUsd: number | null, | |
| 120 | dedupeKey: string | null, | |
| 121 | ]; | |
| 122 | ||
| 123 | interface SerializedAccount { | |
| 124 | readonly accountKey: string; | |
| 125 | readonly sinceMs: number; | |
| 126 | readonly untilMs: number; | |
| 127 | readonly fetchedAtMs: number; | |
| 128 | readonly records: readonly SerializedRecord[]; | |
| 129 | } | |
| 130 | ||
| 131 | /** Serialises the caches by credential source. */ | |
| 132 | export function encodeCursorAccountCaches(caches: ReadonlyMap<string, CursorAccountCache>): string { | |
| 133 | const strings: string[] = []; | |
| 134 | const indexes = new Map<string, number>(); | |
| 135 | const intern = (value: string) => { | |
| 136 | let index = indexes.get(value); | |
| 137 | if (index === undefined) { | |
| 138 | index = strings.length; | |
| 139 | strings.push(value); | |
| 140 | indexes.set(value, index); | |
| 141 | } | |
| 142 | return index; | |
| 143 | }; | |
| 144 | const accounts: Record<string, SerializedAccount> = {}; | |
| 145 | for (const [credentialKey, cache] of caches) { | |
| 146 | accounts[credentialKey] = { | |
| 147 | accountKey: cache.accountKey, | |
| 148 | sinceMs: cache.sinceMs, | |
| 149 | untilMs: cache.untilMs, | |
| 150 | fetchedAtMs: cache.fetchedAtMs, | |
| 151 | records: cache.records.map((record) => [ | |
| 152 | record.timestampMs, | |
| 153 | intern(record.model), | |
| 154 | intern(record.sessionId), | |
| 155 | record.totals.uncachedInputTokens, | |
| 156 | record.totals.cachedInputTokens, | |
| 157 | record.totals.cacheCreationTokens, | |
| 158 | record.totals.outputTokens, | |
| 159 | record.reportedCostUsd, | |
| 160 | record.dedupeKey, | |
| 161 | ]), | |
| 162 | }; | |
| 163 | } | |
| 164 | return JSON.stringify({ version: CURSOR_ACCOUNT_CACHE_VERSION, strings, accounts }); | |
| 165 | } | |
| 166 | ||
| 167 | const isFiniteNumber = (value: unknown): value is number => | |
| 168 | typeof value === "number" && Number.isFinite(value); | |
| 169 | ||
| 170 | /** | |
| 171 | * Rebuilds the caches from a parsed document. A malformed account is dropped | |
| 172 | * whole, costing one refetch; keeping its survivors would leave a gap the | |
| 173 | * covered range claims is complete. | |
| 174 | */ | |
| 175 | export function decodeCursorAccountCaches(document: unknown): Map<string, CursorAccountCache> { | |
| 176 | const caches = new Map<string, CursorAccountCache>(); | |
| 177 | if (typeof document !== "object" || document === null) return caches; | |
| 178 | const root = document as { | |
| 179 | readonly version?: unknown; | |
| 180 | readonly strings?: unknown; | |
| 181 | readonly accounts?: unknown; | |
| 182 | }; | |
| 183 | const strings = root.strings; | |
| 184 | if ( | |
| 185 | root.version !== CURSOR_ACCOUNT_CACHE_VERSION || | |
| 186 | !Array.isArray(strings) || | |
| 187 | !strings.every((value): value is string => typeof value === "string") || | |
| 188 | typeof root.accounts !== "object" || | |
| 189 | root.accounts === null | |
| 190 | ) { | |
| 191 | return caches; | |
| 192 | } | |
| 193 | const stringAt = (index: unknown): string | undefined => | |
| 194 | typeof index === "number" ? strings[index] : undefined; | |
| 195 | ||
| 196 | const decodeRecord = (row: unknown): UsageRecord | null => { | |
| 197 | if (!Array.isArray(row)) return null; | |
| 198 | const [ | |
| 199 | timestampMs, | |
| 200 | modelIndex, | |
| 201 | sessionIndex, | |
| 202 | uncached, | |
| 203 | cached, | |
| 204 | creation, | |
| 205 | output, | |
| 206 | cost, | |
| 207 | key, | |
| 208 | ]: readonly unknown[] = row; | |
| 209 | const model = stringAt(modelIndex); | |
| 210 | const sessionId = stringAt(sessionIndex); | |
| 211 | if ( | |
| 212 | !isFiniteNumber(timestampMs) || | |
| 213 | model === undefined || | |
| 214 | sessionId === undefined || | |
| 215 | !isFiniteNumber(uncached) || | |
| 216 | !isFiniteNumber(cached) || | |
| 217 | !isFiniteNumber(creation) || | |
| 218 | !isFiniteNumber(output) || | |
| 219 | (cost !== null && !isFiniteNumber(cost)) || | |
| 220 | (key !== null && typeof key !== "string") | |
| 221 | ) { | |
| 222 | return null; | |
| 223 | } | |
| 224 | return { | |
| 225 | provider: "cursor", | |
| 226 | timestampMs, | |
| 227 | model, | |
| 228 | rateModel: cursorRateModel(model), | |
| 229 | sessionId, | |
| 230 | totals: { | |
| 231 | uncachedInputTokens: uncached, | |
| 232 | cachedInputTokens: cached, | |
| 233 | cacheCreationTokens: creation, | |
| 234 | outputTokens: output, | |
| 235 | reasoningTokens: 0, | |
| 236 | }, | |
| 237 | reportedCostUsd: cost, | |
| 238 | speed: "standard", | |
| 239 | dedupeKey: key, | |
| 240 | }; | |
| 241 | }; | |
| 242 | ||
| 243 | for (const [credentialKey, raw] of Object.entries(root.accounts)) { | |
| 244 | if (typeof raw !== "object" || raw === null) continue; | |
| 245 | const account = raw as Partial<Record<keyof SerializedAccount, unknown>>; | |
| 246 | if ( | |
| 247 | typeof account.accountKey !== "string" || | |
| 248 | !isFiniteNumber(account.sinceMs) || | |
| 249 | !isFiniteNumber(account.untilMs) || | |
| 250 | !isFiniteNumber(account.fetchedAtMs) || | |
| 251 | !Array.isArray(account.records) | |
| 252 | ) { | |
| 253 | continue; | |
| 254 | } | |
| 255 | const records: UsageRecord[] = []; | |
| 256 | for (const row of account.records) { | |
| 257 | const record = decodeRecord(row); | |
| 258 | if (record === null) break; | |
| 259 | records.push(record); | |
| 260 | } | |
| 261 | if (records.length !== account.records.length) continue; | |
| 262 | caches.set(credentialKey, { | |
| 263 | accountKey: account.accountKey, | |
| 264 | sinceMs: account.sinceMs, | |
| 265 | untilMs: account.untilMs, | |
| 266 | fetchedAtMs: account.fetchedAtMs, | |
| 267 | records, | |
| 268 | }); | |
| 269 | } | |
| 270 | return caches; | |
| 271 | } |
packages/provider-cursor/src/server/accountUsage.test.ts created+340| ... | ... | @@ -0,0 +1,340 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - the reader under test reads a | |
| 2 | // real CLI credential file from disk. | |
| 3 | import * as NodeFSP from "node:fs/promises"; | |
| 4 | import * as NodeOS from "node:os"; | |
| 5 | import * as NodePath from "node:path"; | |
| 6 | ||
| 7 | import { afterEach, assert, beforeEach, describe, it } from "@effect/vitest"; | |
| 8 | ||
| 9 | import { CursorKeychainTimeoutError } from "./CursorKeychain.ts"; | |
| 10 | import { readCursorAccountUsage } from "./accountUsage.ts"; | |
| 11 | ||
| 12 | let dir: string; | |
| 13 | ||
| 14 | beforeEach(async () => { | |
| 15 | dir = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "usage-reader-test-")); | |
| 16 | }); | |
| 17 | ||
| 18 | afterEach(async () => { | |
| 19 | await NodeFSP.rm(dir, { recursive: true, force: true }); | |
| 20 | }); | |
| 21 | ||
| 22 | describe("Cursor account history", () => { | |
| 23 | it("reads Cursor account history with the default macOS Keychain login", async () => { | |
| 24 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 25 | let keychainReads = 0; | |
| 26 | const result = await readCursorAccountUsage( | |
| 27 | { kind: "keychain" }, | |
| 28 | 0, | |
| 29 | 1781000000000, | |
| 30 | async (_url, init) => { | |
| 31 | assert.include(new Headers(init.headers).get("cookie") ?? "", "demo%3A%3A"); | |
| 32 | return Response.json({ totalUsageEventsCount: 0, usageEventsDisplay: [] }); | |
| 33 | }, | |
| 34 | async () => { | |
| 35 | keychainReads++; | |
| 36 | return accessToken; | |
| 37 | }, | |
| 38 | ); | |
| 39 | assert.strictEqual(keychainReads, 1); | |
| 40 | assert.isNull(result.error); | |
| 41 | assert.isFalse(result.missing); | |
| 42 | assert.isNotNull(result.accountKey); | |
| 43 | }); | |
| 44 | ||
| 45 | it("reads paginated Cursor account history including headless calls with separate cache tokens", async () => { | |
| 46 | const authPath = NodePath.join(dir, "auth.json"); | |
| 47 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; | |
| 48 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 49 | const pages: number[] = []; | |
| 50 | const signals: AbortSignal[] = []; | |
| 51 | const request = async (url: string, init: RequestInit) => { | |
| 52 | assert.strictEqual(String(url), "https://cursor.com/api/dashboard/get-filtered-usage-events"); | |
| 53 | assert.strictEqual(init?.redirect, "error"); | |
| 54 | const headers = new Headers(init?.headers); | |
| 55 | assert.strictEqual(headers.get("origin"), "https://cursor.com"); | |
| 56 | assert.include(headers.get("cookie") ?? "", "WorkosCursorSessionToken=demo%3A%3A"); | |
| 57 | const body = JSON.parse(String(init?.body)); | |
| 58 | pages.push(body.page); | |
| 59 | if (init.signal) signals.push(init.signal); | |
| 60 | return Response.json({ | |
| 61 | totalUsageEventsCount: 1001, | |
| 62 | usageEventsDisplay: Array.from({ length: body.page === 1 ? 1000 : 1 }, (_, index) => ({ | |
| 63 | timestamp: String(1780000000000 + ((body.page - 1) * 1000 + index) * 1000), | |
| 64 | model: "claude-sonnet-4-5", | |
| 65 | conversationId: `conversation-${body.page}`, | |
| 66 | isHeadless: body.page === 2, | |
| 67 | chargedCents: 0, | |
| 68 | tokenUsage: { | |
| 69 | inputTokens: 10, | |
| 70 | outputTokens: 5, | |
| 71 | cacheReadTokens: 30, | |
| 72 | cacheWriteTokens: 2, | |
| 73 | totalCents: 25, | |
| 74 | }, | |
| 75 | })), | |
| 76 | }); | |
| 77 | }; | |
| 78 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, request); | |
| 79 | assert.isNull(result.error); | |
| 80 | assert.deepStrictEqual(pages, [1, 2]); | |
| 81 | assert.lengthOf(signals, 2); | |
| 82 | assert.notStrictEqual(signals[0], signals[1]); | |
| 83 | assert.strictEqual(result.records.length, 1001); | |
| 84 | assert.strictEqual(result.records.at(-1)?.sessionId, "conversation-2"); | |
| 85 | assert.deepStrictEqual(result.records[0]?.totals, { | |
| 86 | uncachedInputTokens: 10, | |
| 87 | cachedInputTokens: 30, | |
| 88 | cacheCreationTokens: 2, | |
| 89 | outputTokens: 5, | |
| 90 | reasoningTokens: 0, | |
| 91 | }); | |
| 92 | assert.strictEqual(result.records[0]?.reportedCostUsd, 0.25); | |
| 93 | assert.isFalse(result.accountKey?.includes("demo") ?? true); | |
| 94 | }); | |
| 95 | ||
| 96 | it("reads Cursor account history beyond 100 pages", async () => { | |
| 97 | const authPath = NodePath.join(dir, "auth.json"); | |
| 98 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 99 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 100 | const fullPage = Array.from({ length: 1000 }, () => ({ tokenUsage: null })); | |
| 101 | let requests = 0; | |
| 102 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 103 | requests += 1; | |
| 104 | return Response.json({ | |
| 105 | totalUsageEventsCount: 100_001, | |
| 106 | usageEventsDisplay: requests <= 100 ? fullPage : [{ tokenUsage: null }], | |
| 107 | }); | |
| 108 | }); | |
| 109 | assert.isNull(result.error); | |
| 110 | assert.strictEqual(requests, 101); | |
| 111 | assert.deepStrictEqual(result.records, []); | |
| 112 | }); | |
| 113 | ||
| 114 | it("accepts confirmed empty Cursor usage but rejects error envelopes", async () => { | |
| 115 | const authPath = NodePath.join(dir, "auth.json"); | |
| 116 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 117 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 118 | for (const body of [ | |
| 119 | {}, | |
| 120 | { totalUsageEventsCount: 0 }, | |
| 121 | { totalUsageEventsCount: 0, usageEventsDisplay: [] }, | |
| 122 | ]) { | |
| 123 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => | |
| 124 | Response.json(body), | |
| 125 | ); | |
| 126 | assert.isNull(result.error); | |
| 127 | assert.deepStrictEqual(result.records, []); | |
| 128 | assert.isFalse(result.missing); | |
| 129 | } | |
| 130 | for (const body of [ | |
| 131 | { error: "upstream error" }, | |
| 132 | { detail: "unknown error envelope" }, | |
| 133 | { totalUsageEventsCount: 0, error: "upstream error" }, | |
| 134 | null, | |
| 135 | [], | |
| 136 | "invalid", | |
| 137 | 0, | |
| 138 | ]) { | |
| 139 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => | |
| 140 | Response.json(body), | |
| 141 | ); | |
| 142 | assert.isNotNull(result.error); | |
| 143 | assert.deepStrictEqual(result.records, []); | |
| 144 | } | |
| 145 | }); | |
| 146 | ||
| 147 | it("requires a terminal Cursor page after a full page reaches the reported count", async () => { | |
| 148 | const authPath = NodePath.join(dir, "auth.json"); | |
| 149 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 150 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 151 | let requests = 0; | |
| 152 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 153 | requests++; | |
| 154 | return Response.json( | |
| 155 | requests === 1 | |
| 156 | ? { | |
| 157 | totalUsageEventsCount: 1000, | |
| 158 | usageEventsDisplay: Array.from({ length: 1000 }, (_, index) => ({ | |
| 159 | timestamp: String(1780000000000 + index), | |
| 160 | model: "gpt-5", | |
| 161 | tokenUsage: { inputTokens: 10, outputTokens: 5 }, | |
| 162 | })), | |
| 163 | } | |
| 164 | : { totalUsageEventsCount: 1000 }, | |
| 165 | ); | |
| 166 | }); | |
| 167 | assert.isNull(result.error); | |
| 168 | assert.strictEqual(result.records.length, 1000); | |
| 169 | assert.strictEqual(requests, 2); | |
| 170 | }); | |
| 171 | ||
| 172 | it("removes only count-proven Cursor boundary copies and preserves identical billed events", async () => { | |
| 173 | const authPath = NodePath.join(dir, "auth.json"); | |
| 174 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 175 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 176 | const event = (index: number) => ({ | |
| 177 | timestamp: String(1780000000000 + index), | |
| 178 | model: "gpt-5", | |
| 179 | tokenUsage: { inputTokens: 10, outputTokens: 5, totalCents: 1 }, | |
| 180 | }); | |
| 181 | for (const total of [2000, 2001]) { | |
| 182 | let requests = 0; | |
| 183 | const result = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 184 | requests++; | |
| 185 | return Response.json({ | |
| 186 | totalUsageEventsCount: total, | |
| 187 | usageEventsDisplay: | |
| 188 | requests === 1 | |
| 189 | ? Array.from({ length: 1000 }, (_, index) => event(index)) | |
| 190 | : requests === 2 | |
| 191 | ? Array.from({ length: 1000 }, (_, index) => event(999 + index)) | |
| 192 | : [event(1999)], | |
| 193 | }); | |
| 194 | }); | |
| 195 | assert.isNull(result.error); | |
| 196 | assert.strictEqual(result.records.length, total); | |
| 197 | assert.strictEqual(requests, 3); | |
| 198 | assert.strictEqual(result.records.at(-1)?.timestampMs, 1780000001999); | |
| 199 | assert.strictEqual( | |
| 200 | result.records.filter((record) => record.timestampMs === 1780000000999).length, | |
| 201 | total === 2000 ? 1 : 2, | |
| 202 | ); | |
| 203 | assert.strictEqual(new Set(result.records.map((record) => record.dedupeKey)).size, total); | |
| 204 | } | |
| 205 | let requests = 0; | |
| 206 | const inconsistent = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => { | |
| 207 | requests++; | |
| 208 | return Response.json({ | |
| 209 | totalUsageEventsCount: 1001, | |
| 210 | usageEventsDisplay: | |
| 211 | requests === 1 | |
| 212 | ? Array.from({ length: 1000 }, (_, index) => event(index)) | |
| 213 | : [event(500), event(1000)], | |
| 214 | }); | |
| 215 | }); | |
| 216 | assert.isNotNull(inconsistent.error); | |
| 217 | assert.deepStrictEqual(inconsistent.records, []); | |
| 218 | }); | |
| 219 | ||
| 220 | it("does not present truncated Cursor account pages or authentication failures as complete history", async () => { | |
| 221 | const authPath = NodePath.join(dir, "auth.json"); | |
| 222 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo", exp: 4102444800 })).toString("base64url")}.signature`; | |
| 223 | await NodeFSP.writeFile(authPath, JSON.stringify({ accessToken })); | |
| 224 | const truncated = await readCursorAccountUsage(authPath, 0, 1781000000000, async () => | |
| 225 | Response.json({ totalUsageEventsCount: 101, usageEventsDisplay: [] }), | |
| 226 | ); | |
| 227 | assert.isNotNull(truncated.error); | |
| 228 | assert.deepStrictEqual(truncated.records, []); | |
| 229 | const denied = await readCursorAccountUsage( | |
| 230 | authPath, | |
| 231 | 0, | |
| 232 | 1781000000000, | |
| 233 | async () => new Response(accessToken, { status: 401 }), | |
| 234 | ); | |
| 235 | assert.isNotNull(denied.error); | |
| 236 | assert.isFalse(denied.error?.includes(accessToken) ?? true); | |
| 237 | assert.deepStrictEqual(denied.records, []); | |
| 238 | let requested = false; | |
| 239 | const missing = await readCursorAccountUsage( | |
| 240 | NodePath.join(dir, "missing.json"), | |
| 241 | 0, | |
| 242 | 1781000000000, | |
| 243 | async () => { | |
| 244 | requested = true; | |
| 245 | return Response.json({}); | |
| 246 | }, | |
| 247 | ); | |
| 248 | assert.isTrue(missing.missing); | |
| 249 | assert.isFalse(requested); | |
| 250 | }); | |
| 251 | }); | |
| 252 | ||
| 253 | describe("readCursorAccountUsage", () => { | |
| 254 | it("asks for Keychain approval when the prompt goes unanswered", async () => { | |
| 255 | const result = await readCursorAccountUsage( | |
| 256 | { kind: "keychain" }, | |
| 257 | 0, | |
| 258 | 1, | |
| 259 | () => Promise.reject(new Error("no network expected")), | |
| 260 | () => Promise.reject(new CursorKeychainTimeoutError()), | |
| 261 | ); | |
| 262 | assert.deepStrictEqual(result, { | |
| 263 | accountKey: null, | |
| 264 | records: [], | |
| 265 | missing: false, | |
| 266 | error: "Allow Keychain access on the Mac running T3 Code, then refresh.", | |
| 267 | }); | |
| 268 | }); | |
| 269 | ||
| 270 | it("reads the pages behind the first together and keeps them in page order", async () => { | |
| 271 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 272 | let inFlight = 0; | |
| 273 | let mostInFlight = 0; | |
| 274 | const result = await readCursorAccountUsage( | |
| 275 | { kind: "keychain" }, | |
| 276 | 0, | |
| 277 | 1781000000000, | |
| 278 | async (_url, init) => { | |
| 279 | const page: number = JSON.parse(String(init.body)).page; | |
| 280 | inFlight++; | |
| 281 | mostInFlight = Math.max(mostInFlight, inFlight); | |
| 282 | // Later pages answer first, so record order cannot come from arrival order. | |
| 283 | for (let turn = page; turn < 9; turn++) await Promise.resolve(); | |
| 284 | inFlight--; | |
| 285 | return Response.json({ | |
| 286 | totalUsageEventsCount: 8001, | |
| 287 | usageEventsDisplay: Array.from({ length: page === 9 ? 1 : 1000 }, (_, index) => ({ | |
| 288 | timestamp: String(1780000000000 + (page - 1) * 1000 + index), | |
| 289 | model: "gpt-5", | |
| 290 | tokenUsage: { inputTokens: 1 }, | |
| 291 | })), | |
| 292 | }); | |
| 293 | }, | |
| 294 | async () => accessToken, | |
| 295 | ); | |
| 296 | assert.isNull(result.error); | |
| 297 | assert.strictEqual(mostInFlight, 6); | |
| 298 | assert.deepStrictEqual( | |
| 299 | result.records.map((record) => record.timestampMs), | |
| 300 | Array.from({ length: 8001 }, (_, index) => 1780000000000 + index), | |
| 301 | ); | |
| 302 | }); | |
| 303 | ||
| 304 | it("ends like a page-by-page read when a page fails, leaving no request open", async () => { | |
| 305 | const accessToken = `header.${Buffer.from(JSON.stringify({ sub: "auth|demo" })).toString("base64url")}.signature`; | |
| 306 | const fullPage = { | |
| 307 | totalUsageEventsCount: 8001, | |
| 308 | usageEventsDisplay: Array.from({ length: 1000 }, () => ({ tokenUsage: null })), | |
| 309 | }; | |
| 310 | // Page 3 fails while the pages after it are still waiting on Cursor. | |
| 311 | for (const [secondPage, error] of [ | |
| 312 | [() => Response.json(fullPage), "Cursor account usage could not be read."], | |
| 313 | [() => new Response(null, { status: 401 }), "Sign in to Cursor again to read account usage."], | |
| 314 | ] as const) { | |
| 315 | let open = 0; | |
| 316 | const result = await readCursorAccountUsage( | |
| 317 | { kind: "keychain" }, | |
| 318 | 0, | |
| 319 | 1781000000000, | |
| 320 | (_url, init) => { | |
| 321 | const page: number = JSON.parse(String(init.body)).page; | |
| 322 | if (page === 1) return Promise.resolve(Response.json(fullPage)); | |
| 323 | if (page === 2) return Promise.resolve(secondPage()); | |
| 324 | if (page === 3) return Promise.reject(new Error("connection reset")); | |
| 325 | open++; | |
| 326 | return new Promise((_resolve, reject) => { | |
| 327 | init.signal?.addEventListener("abort", () => { | |
| 328 | open--; | |
| 329 | reject(init.signal?.reason); | |
| 330 | }); | |
| 331 | }); | |
| 332 | }, | |
| 333 | async () => accessToken, | |
| 334 | ); | |
| 335 | assert.strictEqual(result.error, error); | |
| 336 | assert.deepStrictEqual(result.records, []); | |
| 337 | assert.strictEqual(open, 0); | |
| 338 | } | |
| 339 | }); | |
| 340 | }); |
packages/provider-cursor/src/server/accountUsage.ts created+300| ... | ... | @@ -0,0 +1,300 @@ |
| 1 | // Node fs reads CLI credentials, and crypto hashes account IDs for deduplication. | |
| 2 | // @effect-diagnostics nodeBuiltinImport:off | |
| 3 | /** | |
| 4 | * Cursor account usage from its dashboard API, read with the saved CLI or | |
| 5 | * Keychain login. The dashboard covers CLI, desktop and headless usage from | |
| 6 | * every machine on the account. | |
| 7 | * | |
| 8 | * @module provider-cursor/server/accountUsage | |
| 9 | */ | |
| 10 | import * as NodeFSP from "node:fs/promises"; | |
| 11 | import * as NodeCrypto from "node:crypto"; | |
| 12 | import * as NodeTimersPromises from "node:timers/promises"; | |
| 13 | ||
| 14 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 15 | ||
| 16 | import { isCursorKeychainTimeoutError } from "./CursorKeychain.ts"; | |
| 17 | ||
| 18 | function object(value: unknown): Record<string, unknown> { | |
| 19 | return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 20 | ? (value as Record<string, unknown>) | |
| 21 | : {}; | |
| 22 | } | |
| 23 | ||
| 24 | function tokens(value: unknown): number { | |
| 25 | return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; | |
| 26 | } | |
| 27 | ||
| 28 | /** | |
| 29 | * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`, | |
| 30 | * `claude-fable-5-1-thinking-high`) to the base model's rate-table key. | |
| 31 | * Grok resolves through xAI's first-party entry, which has no bare alias. | |
| 32 | */ | |
| 33 | export function cursorRateModel(model: string): string { | |
| 34 | const base = model | |
| 35 | .replace(/^cursor-/, "") | |
| 36 | .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, ""); | |
| 37 | return base.startsWith("grok-") ? `xai/${base}` : base; | |
| 38 | } | |
| 39 | ||
| 40 | export interface CursorAccountUsageReadResult { | |
| 41 | readonly accountKey: string | null; | |
| 42 | readonly records: readonly UsageRecord[]; | |
| 43 | readonly missing: boolean; | |
| 44 | readonly error: string | null; | |
| 45 | } | |
| 46 | ||
| 47 | const accountHash = (value: string) => NodeCrypto.createHash("sha256").update(value).digest("hex"); | |
| 48 | ||
| 49 | function canonicalJson(value: unknown): string { | |
| 50 | if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; | |
| 51 | if (value !== null && typeof value === "object") { | |
| 52 | return `{${Object.entries(value) | |
| 53 | .sort(([a], [b]) => a.localeCompare(b)) | |
| 54 | .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) | |
| 55 | .join(",")}}`; | |
| 56 | } | |
| 57 | return JSON.stringify(value) ?? "null"; | |
| 58 | } | |
| 59 | ||
| 60 | /** Find the longest exact suffix/prefix overlap in linear time. */ | |
| 61 | function boundaryOverlap(previous: readonly string[], current: readonly string[]): number { | |
| 62 | const sequence = [...current, "", ...previous]; | |
| 63 | const lengths = Array.from({ length: sequence.length }, () => 0); | |
| 64 | for (let index = 1; index < sequence.length; index++) { | |
| 65 | let length = lengths[index - 1]!; | |
| 66 | while (length > 0 && sequence[index] !== sequence[length]) length = lengths[length - 1]!; | |
| 67 | if (sequence[index] === sequence[length]) length++; | |
| 68 | lengths[index] = length; | |
| 69 | } | |
| 70 | return lengths.at(-1) ?? 0; | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * Dashboard usage includes headless agents and reports fresh input separately | |
| 75 | * from cache reads. `keychainToken` is only called for a Keychain login. | |
| 76 | */ | |
| 77 | export async function readCursorAccountUsage( | |
| 78 | credentialSource: string | { readonly kind: "keychain" }, | |
| 79 | sinceMs: number, | |
| 80 | endDate: number, | |
| 81 | request: (url: string, init: RequestInit) => Promise<Response> = globalThis.fetch, | |
| 82 | keychainToken: () => Promise<string | null> = () => | |
| 83 | Promise.reject(new Error("No Keychain reader")), | |
| 84 | ): Promise<CursorAccountUsageReadResult> { | |
| 85 | let accessToken: unknown; | |
| 86 | try { | |
| 87 | accessToken = | |
| 88 | typeof credentialSource === "string" | |
| 89 | ? object(JSON.parse(await NodeFSP.readFile(credentialSource, "utf8"))).accessToken | |
| 90 | : await keychainToken(); | |
| 91 | } catch (cause) { | |
| 92 | const missing = typeof credentialSource === "string" && object(cause).code === "ENOENT"; | |
| 93 | return { | |
| 94 | accountKey: null, | |
| 95 | records: [], | |
| 96 | missing, | |
| 97 | error: missing | |
| 98 | ? null | |
| 99 | : typeof credentialSource === "string" | |
| 100 | ? "Cursor credentials could not be read." | |
| 101 | : isCursorKeychainTimeoutError(cause) | |
| 102 | ? "Allow Keychain access on the Mac running T3 Code, then refresh." | |
| 103 | : "Cursor Keychain credentials could not be read.", | |
| 104 | }; | |
| 105 | } | |
| 106 | if (typeof accessToken !== "string" || !accessToken) { | |
| 107 | return { | |
| 108 | accountKey: null, | |
| 109 | records: [], | |
| 110 | missing: true, | |
| 111 | error: | |
| 112 | typeof credentialSource === "string" | |
| 113 | ? null | |
| 114 | : "Cursor account history needs a macOS Keychain CLI login on this server.", | |
| 115 | }; | |
| 116 | } | |
| 117 | let accountKey: string | null = null; | |
| 118 | const cancel = new AbortController(); | |
| 119 | try { | |
| 120 | const payload = accessToken.split(".")[1]; | |
| 121 | const subject = object( | |
| 122 | JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), | |
| 123 | ).sub; | |
| 124 | if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication"); | |
| 125 | const userId = subject.split("|").at(-1); | |
| 126 | if (!userId) throw new Error("Invalid authentication"); | |
| 127 | accountKey = accountHash(subject); | |
| 128 | if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) | |
| 129 | throw new Error("Invalid date window"); | |
| 130 | const deadline = AbortSignal.any([cancel.signal, AbortSignal.timeout(60_000)]); | |
| 131 | const records: UsageRecord[] = []; | |
| 132 | const occurrences = new Map<string, number>(); | |
| 133 | const pages: unknown[][] = []; | |
| 134 | let completed = false; | |
| 135 | const pageSize = 1000; | |
| 136 | let total: number | undefined; | |
| 137 | const readPage = async (page: number) => { | |
| 138 | // A count can include overlapping page boundaries. Allow room to | |
| 139 | // reconcile them without imposing a fixed account-size limit. | |
| 140 | if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { | |
| 141 | throw new Error("Account usage page limit exceeded"); | |
| 142 | } | |
| 143 | const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { | |
| 144 | method: "POST", | |
| 145 | redirect: "error", | |
| 146 | signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), | |
| 147 | headers: { | |
| 148 | "Content-Type": "application/json", | |
| 149 | Origin: "https://cursor.com", | |
| 150 | Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, | |
| 151 | }, | |
| 152 | body: JSON.stringify({ | |
| 153 | page, | |
| 154 | pageSize, | |
| 155 | startDate: String(sinceMs), | |
| 156 | endDate: String(endDate), | |
| 157 | }), | |
| 158 | }); | |
| 159 | if (response.status === 401 || response.status === 403) { | |
| 160 | return { | |
| 161 | accountKey, | |
| 162 | records: [], | |
| 163 | missing: false, | |
| 164 | error: "Sign in to Cursor again to read account usage.", | |
| 165 | }; | |
| 166 | } | |
| 167 | if (!response.ok) throw new Error("Account usage request failed"); | |
| 168 | const parsed: unknown = await response.json(); | |
| 169 | if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { | |
| 170 | throw new Error("Invalid account usage page"); | |
| 171 | } | |
| 172 | const body = object(parsed); | |
| 173 | const keys = Object.keys(body); | |
| 174 | if ("error" in body || "message" in body || "code" in body) | |
| 175 | throw new Error("Account usage error response"); | |
| 176 | const count = keys.length === 0 ? 0 : body.totalUsageEventsCount; | |
| 177 | const events = | |
| 178 | keys.length === 0 || (keys.length === 1 && keys[0] === "totalUsageEventsCount") | |
| 179 | ? [] | |
| 180 | : body.usageEventsDisplay; | |
| 181 | if ( | |
| 182 | (count !== undefined && | |
| 183 | (typeof count !== "number" || | |
| 184 | !Number.isSafeInteger(count) || | |
| 185 | count < 0 || | |
| 186 | (total !== undefined && count !== total))) || | |
| 187 | !Array.isArray(events) || | |
| 188 | events.length > pageSize || | |
| 189 | (count === undefined && !Array.isArray(body.usageEventsDisplay)) | |
| 190 | ) { | |
| 191 | throw new Error("Inconsistent account usage page"); | |
| 192 | } | |
| 193 | if (typeof count === "number") total = count; | |
| 194 | return events; | |
| 195 | }; | |
| 196 | // Cursor caps a page at 1,000 events and takes about a second to answer one, so the pages | |
| 197 | // the first one's count implies are requested up to six ahead and consumed in page order. | |
| 198 | const ahead: ReturnType<typeof readPage>[] = []; | |
| 199 | for (let page = 1; ; page++) { | |
| 200 | while (ahead.length < Math.min(6, Math.ceil((total ?? 0) / pageSize) - page + 1)) { | |
| 201 | const pending = readPage(page + ahead.length); | |
| 202 | // Handled when its turn comes, or cancelled if the read ends first. | |
| 203 | pending.catch(() => undefined); | |
| 204 | ahead.push(pending); | |
| 205 | } | |
| 206 | const events = await (ahead.shift() ?? readPage(page)); | |
| 207 | // A rejected login comes back as the finished result instead of a page. | |
| 208 | if (!Array.isArray(events)) return events; | |
| 209 | pages.push(events); | |
| 210 | if (events.length < pageSize) { | |
| 211 | completed = true; | |
| 212 | break; | |
| 213 | } | |
| 214 | } | |
| 215 | if (!completed) throw new Error("Account usage page limit exceeded"); | |
| 216 | const rawCount = pages.reduce((sum, page) => sum + page.length, 0); | |
| 217 | if (total !== undefined && rawCount < total) throw new Error("Incomplete account usage pages"); | |
| 218 | let removalsRemaining = total === undefined ? 0 : rawCount - total; | |
| 219 | let previousKeys: string[] = []; | |
| 220 | for (const events of pages) { | |
| 221 | const eventKeys = | |
| 222 | removalsRemaining > 0 ? events.map((event) => accountHash(canonicalJson(event))) : []; | |
| 223 | const removalCount = Math.min(removalsRemaining, boundaryOverlap(previousKeys, eventKeys)); | |
| 224 | removalsRemaining -= removalCount; | |
| 225 | previousKeys = eventKeys; | |
| 226 | for (const raw of events.slice(removalCount)) { | |
| 227 | const event = object(raw); | |
| 228 | const usage = object(event.tokenUsage); | |
| 229 | if (event.tokenUsage === undefined || event.tokenUsage === null) continue; | |
| 230 | for (const key of [ | |
| 231 | "inputTokens", | |
| 232 | "outputTokens", | |
| 233 | "cacheReadTokens", | |
| 234 | "cacheWriteTokens", | |
| 235 | "totalCents", | |
| 236 | ]) { | |
| 237 | const value = usage[key]; | |
| 238 | if ( | |
| 239 | value !== undefined && | |
| 240 | (typeof value !== "number" || !Number.isFinite(value) || value < 0) | |
| 241 | ) { | |
| 242 | throw new Error("Invalid account usage totals"); | |
| 243 | } | |
| 244 | } | |
| 245 | const timestampMs = | |
| 246 | typeof event.timestamp === "string" && event.timestamp.trim() !== "" | |
| 247 | ? Number(event.timestamp) | |
| 248 | : event.timestamp; | |
| 249 | if ( | |
| 250 | typeof timestampMs !== "number" || | |
| 251 | !Number.isFinite(timestampMs) || | |
| 252 | typeof event.model !== "string" || | |
| 253 | !event.model | |
| 254 | ) | |
| 255 | throw new Error("Invalid account usage event"); | |
| 256 | if (timestampMs < sinceMs || timestampMs > endDate) continue; | |
| 257 | const totals = { | |
| 258 | uncachedInputTokens: tokens(usage.inputTokens), | |
| 259 | cachedInputTokens: tokens(usage.cacheReadTokens), | |
| 260 | cacheCreationTokens: tokens(usage.cacheWriteTokens), | |
| 261 | outputTokens: tokens(usage.outputTokens), | |
| 262 | reasoningTokens: 0, | |
| 263 | }; | |
| 264 | const reportedCostUsd = | |
| 265 | typeof usage.totalCents === "number" ? usage.totalCents / 100 : null; | |
| 266 | const sessionId = typeof event.conversationId === "string" ? event.conversationId : ""; | |
| 267 | // No event ID is provided. Preserve identical billed rows with an occurrence index. | |
| 268 | const key = accountHash( | |
| 269 | JSON.stringify([timestampMs, event.model, sessionId, totals, reportedCostUsd]), | |
| 270 | ); | |
| 271 | const occurrence = occurrences.get(key) ?? 0; | |
| 272 | occurrences.set(key, occurrence + 1); | |
| 273 | records.push({ | |
| 274 | provider: "cursor", | |
| 275 | timestampMs, | |
| 276 | model: event.model, | |
| 277 | rateModel: cursorRateModel(event.model), | |
| 278 | sessionId, | |
| 279 | totals, | |
| 280 | reportedCostUsd, | |
| 281 | speed: "standard", | |
| 282 | dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`, | |
| 283 | }); | |
| 284 | } | |
| 285 | await NodeTimersPromises.setImmediate(); | |
| 286 | } | |
| 287 | if (removalsRemaining !== 0) throw new Error("Inconsistent account usage boundaries"); | |
| 288 | return { accountKey, records, missing: false, error: null }; | |
| 289 | } catch { | |
| 290 | return { | |
| 291 | accountKey, | |
| 292 | records: [], | |
| 293 | missing: false, | |
| 294 | error: "Cursor account usage could not be read.", | |
| 295 | }; | |
| 296 | } finally { | |
| 297 | // An early exit leaves the pages requested ahead of it in flight. | |
| 298 | cancel.abort(); | |
| 299 | } | |
| 300 | } |
packages/provider-cursor/src/server/driver.test.ts+6| ... | ... | @@ -15,6 +15,7 @@ import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 15 | 15 | import { CursorDriver } from "./driver.ts"; |
| 16 | 16 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 17 | 17 | import * as CursorSdk from "./CursorSdk.ts"; |
| 18 | import * as CursorKeychain from "./CursorKeychain.ts"; | |
| 18 | 19 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 19 | 20 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 20 | 21 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| ... | ... | @@ -30,6 +31,11 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( |
| 30 | 31 | open: () => Effect.die("Maintenance resolution must not open a Cursor session"), |
| 31 | 32 | }), |
| 32 | 33 | ), |
| 34 | Layer.provideMerge( | |
| 35 | Layer.succeed(CursorKeychain.CursorKeychain, { | |
| 36 | accessToken: Effect.die("The driver test must not read the Keychain"), | |
| 37 | }), | |
| 38 | ), | |
| 33 | 39 | Layer.provideMerge( |
| 34 | 40 | Layer.succeed( |
| 35 | 41 | ProviderEventLoggers.ProviderEventLoggers, |
packages/provider-cursor/src/server/driver.ts+28-2| ... | ... | @@ -41,6 +41,9 @@ import { makeCursorAuth } from "./auth.ts"; |
| 41 | 41 | import * as CursorCredentialStore from "./credentialStore.ts"; |
| 42 | 42 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 43 | 43 | import * as CursorSdk from "./CursorSdk.ts"; |
| 44 | import * as CursorKeychain from "./CursorKeychain.ts"; | |
| 45 | import * as CursorUsageAccounts from "./CursorUsageAccounts.ts"; | |
| 46 | ||
| 44 | 47 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 45 | 48 | const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); |
| 46 | 49 | |
| ... | ... | @@ -57,9 +60,14 @@ export type CursorDriverEnv = |
| 57 | 60 | | Path.Path |
| 58 | 61 | | HttpClient.HttpClient |
| 59 | 62 | | CursorSdk.CursorSdk |
| 60 | | ProviderHost.ProviderHost; | |
| 63 | | ProviderHost.ProviderHost | |
| 64 | | CursorKeychain.CursorKeychain; | |
| 61 | 65 | |
| 62 | export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | |
| 66 | export const CursorDriver: ProviderDriver< | |
| 67 | CursorSettings, | |
| 68 | CursorDriverEnv, | |
| 69 | CursorUsageAccounts.CursorUsageAccounts | |
| 70 | > = { | |
| 63 | 71 | driverKind: DRIVER_KIND, |
| 64 | 72 | metadata: { |
| 65 | 73 | displayName: "Cursor", |
| ... | ... | @@ -67,12 +75,29 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 67 | 75 | }, |
| 68 | 76 | configSchema: CursorSettings, |
| 69 | 77 | defaultConfig: (): CursorSettings => decodeCursorSettings({}), |
| 78 | // One account source per environment: the host's Cursor CLI login. | |
| 79 | usage: { | |
| 80 | kind: "scan", | |
| 81 | provider: "cursor", | |
| 82 | scan: ({ settings, windowStartMs, retentionCutoffMs, awaitRefresh }) => | |
| 83 | CursorUsageAccounts.CursorUsageAccounts.pipe( | |
| 84 | Effect.flatMap((accounts) => | |
| 85 | accounts.scan({ | |
| 86 | keychainUsageEnabled: settings.cursorKeychainUsageEnabled, | |
| 87 | windowStartMs, | |
| 88 | retentionCutoffMs, | |
| 89 | awaitRefresh, | |
| 90 | }), | |
| 91 | ), | |
| 92 | ), | |
| 93 | }, | |
| 70 | 94 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
| 71 | 95 | Effect.gen(function* () { |
| 72 | 96 | const host = yield* ProviderHost.ProviderHost; |
| 73 | 97 | const fileSystem = yield* FileSystem.FileSystem; |
| 74 | 98 | const path = yield* Path.Path; |
| 75 | 99 | const httpClient = yield* HttpClient.HttpClient; |
| 100 | const keychain = yield* CursorKeychain.CursorKeychain; | |
| 76 | 101 | const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; |
| 77 | 102 | const processEnv = mergeProviderInstanceEnvironment(environment); |
| 78 | 103 | const continuationIdentity = defaultProviderContinuationIdentity({ |
| ... | ... | @@ -221,6 +246,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 221 | 246 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 222 | 247 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| 223 | 248 | Effect.provideService(Path.Path, path), |
| 249 | Effect.provideService(CursorKeychain.CursorKeychain, keychain), | |
| 224 | 250 | Effect.map(stampSnapshot), |
| 225 | 251 | Effect.provide(CursorSdkCatalog.layer), |
| 226 | 252 | ); |
packages/provider-cursor/src/server/keychainToken.test.ts deleted-43| ... | ... | @@ -1,43 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { CursorKeychainTimeoutError, makeCachedCursorAccessTokenReader } from "./keychainToken.ts"; | |
| 4 | ||
| 5 | describe("Cursor Keychain reader", () => { | |
| 6 | it("shares concurrent reads and rechecks after the cache expires", async () => { | |
| 7 | let reads = 0; | |
| 8 | let time = 0; | |
| 9 | const read = makeCachedCursorAccessTokenReader( | |
| 10 | async () => { | |
| 11 | reads++; | |
| 12 | return `token-${reads}`; | |
| 13 | }, | |
| 14 | () => time, | |
| 15 | ); | |
| 16 | assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); | |
| 17 | assert.strictEqual(await read(), "token-1"); | |
| 18 | assert.strictEqual(reads, 1); | |
| 19 | time = 5 * 60_000; | |
| 20 | assert.strictEqual(await read(), "token-2"); | |
| 21 | }); | |
| 22 | ||
| 23 | it("gives up on an unanswered prompt and reuses it on the next read", async () => { | |
| 24 | let reads = 0; | |
| 25 | let allow: (token: string) => void = () => {}; | |
| 26 | const read = makeCachedCursorAccessTokenReader( | |
| 27 | () => { | |
| 28 | reads++; | |
| 29 | return new Promise((resolve) => { | |
| 30 | allow = resolve; | |
| 31 | }); | |
| 32 | }, | |
| 33 | () => 0, | |
| 34 | 1, | |
| 35 | ); | |
| 36 | const error = await read().catch((cause: unknown) => cause); | |
| 37 | assert.instanceOf(error, CursorKeychainTimeoutError); | |
| 38 | const retry = read(); | |
| 39 | allow("token"); | |
| 40 | assert.strictEqual(await retry, "token"); | |
| 41 | assert.strictEqual(reads, 1); | |
| 42 | }); | |
| 43 | }); |
packages/provider-cursor/src/server/keychainToken.ts deleted-55| ... | ... | @@ -1,55 +0,0 @@ |
| 1 | import * as NodeModule from "node:module"; | |
| 2 | ||
| 3 | const CACHE_MS = 5 * 60_000; | |
| 4 | ||
| 5 | const requireForKeyring = NodeModule.createRequire(import.meta.url); | |
| 6 | ||
| 7 | /** Rejected when nobody answers the macOS Keychain prompt in time. */ | |
| 8 | export class CursorKeychainTimeoutError extends Error { | |
| 9 | constructor() { | |
| 10 | super("Timed out waiting for Keychain access."); | |
| 11 | } | |
| 12 | } | |
| 13 | ||
| 14 | /** | |
| 15 | * Share one Keychain request across usage history and limits in this server process. | |
| 16 | * | |
| 17 | * macOS shows the access prompt on the server's own screen, which a remote | |
| 18 | * client cannot answer, so callers give up after `timeoutMs`. The read stays in | |
| 19 | * flight: the next call reuses it instead of stacking a second prompt, and picks | |
| 20 | * up the token once someone allows access. | |
| 21 | */ | |
| 22 | export function makeCachedCursorAccessTokenReader( | |
| 23 | read: () => Promise<string | null>, | |
| 24 | now: () => number = Date.now, | |
| 25 | timeoutMs = 30_000, | |
| 26 | ): () => Promise<string | null> { | |
| 27 | let cached: { token: string; until: number } | null = null; | |
| 28 | let pending: Promise<string | null> | null = null; | |
| 29 | return () => { | |
| 30 | if (cached && cached.until > now()) return Promise.resolve(cached.token); | |
| 31 | pending ??= read() | |
| 32 | .then((token) => { | |
| 33 | cached = token ? { token, until: now() + CACHE_MS } : null; | |
| 34 | return token; | |
| 35 | }) | |
| 36 | .finally(() => { | |
| 37 | pending = null; | |
| 38 | }); | |
| 39 | const deadline = AbortSignal.timeout(timeoutMs); | |
| 40 | return Promise.race([ | |
| 41 | pending, | |
| 42 | new Promise<never>((_, reject) => { | |
| 43 | deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { | |
| 44 | once: true, | |
| 45 | }); | |
| 46 | }), | |
| 47 | ]); | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | /** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ | |
| 52 | export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { | |
| 53 | const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); | |
| 54 | return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; | |
| 55 | }); |
packages/provider-cursor/src/server/usageLimits.test.ts+20-12| ... | ... | @@ -6,10 +6,19 @@ import * as FileSystem from "effect/FileSystem"; |
| 6 | 6 | import * as Path from "effect/Path"; |
| 7 | 7 | import * as HttpClient from "effect/http/HttpClient"; |
| 8 | 8 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 9 | import * as CursorKeychain from "./CursorKeychain.ts"; | |
| 9 | 10 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; |
| 10 | 11 | |
| 11 | const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => | |
| 12 | effect.pipe(Effect.provide(NodeServices.layer)); | |
| 12 | const withNodeServices = <A, E>( | |
| 13 | effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | CursorKeychain.CursorKeychain>, | |
| 14 | accessToken: CursorKeychain.CursorKeychain["Service"]["accessToken"] = Effect.die( | |
| 15 | "must not read Keychain", | |
| 16 | ), | |
| 17 | ) => | |
| 18 | effect.pipe( | |
| 19 | Effect.provideService(CursorKeychain.CursorKeychain, { accessToken }), | |
| 20 | Effect.provide(NodeServices.layer), | |
| 21 | ); | |
| 13 | 22 | |
| 14 | 23 | describe("Cursor usage limits", () => { |
| 15 | 24 | const checkedAt = "2026-09-16T00:00:00.000Z"; |
| ... | ... | @@ -121,9 +130,6 @@ describe("Cursor usage limits", () => { |
| 121 | 130 | ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), |
| 122 | 131 | }, |
| 123 | 132 | false, |
| 124 | async () => { | |
| 125 | throw new Error("must not read Keychain before opt-in"); | |
| 126 | }, | |
| 127 | 133 | ).pipe( |
| 128 | 134 | Effect.provideService(HostProcessPlatform, platform), |
| 129 | 135 | Effect.provideService( |
| ... | ... | @@ -195,7 +201,7 @@ describe("Cursor usage limits", () => { |
| 195 | 201 | it.effect("reads the default macOS Cursor login from Keychain for limits", () => |
| 196 | 202 | Effect.gen(function* () { |
| 197 | 203 | const limits = yield* withNodeServices( |
| 198 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( | |
| 204 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( | |
| 199 | 205 | Effect.provideService(HostProcessPlatform, "darwin"), |
| 200 | 206 | Effect.provideService( |
| 201 | 207 | FileSystem.FileSystem, |
| ... | ... | @@ -216,6 +222,7 @@ describe("Cursor usage limits", () => { |
| 216 | 222 | }), |
| 217 | 223 | ), |
| 218 | 224 | ), |
| 225 | Effect.succeed("keychain-token"), | |
| 219 | 226 | ); |
| 220 | 227 | expect(limits.windows[0]?.usedPercent).toBe(42); |
| 221 | 228 | }), |
| ... | ... | @@ -224,15 +231,18 @@ describe("Cursor usage limits", () => { |
| 224 | 231 | it.effect("reports a Keychain initialization failure without failing the provider refresh", () => |
| 225 | 232 | Effect.gen(function* () { |
| 226 | 233 | const limits = yield* withNodeServices( |
| 227 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { | |
| 228 | throw new Error("Keychain initialization failed"); | |
| 229 | }).pipe( | |
| 234 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true).pipe( | |
| 230 | 235 | Effect.provideService(HostProcessPlatform, "darwin"), |
| 231 | 236 | Effect.provideService( |
| 232 | 237 | HttpClient.HttpClient, |
| 233 | 238 | HttpClient.make(() => Effect.die("must not request limits without a login")), |
| 234 | 239 | ), |
| 235 | 240 | ), |
| 241 | Effect.fail( | |
| 242 | new CursorKeychain.CursorKeychainReadError({ | |
| 243 | cause: new Error("Keychain initialization failed"), | |
| 244 | }), | |
| 245 | ), | |
| 236 | 246 | ); |
| 237 | 247 | expect(limits.unavailable?.reason).toBe("probeFailed"); |
| 238 | 248 | }), |
| ... | ... | @@ -247,9 +257,7 @@ describe("Cursor usage limits", () => { |
| 247 | 257 | ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], |
| 248 | 258 | ] as const) { |
| 249 | 259 | const limits = yield* withNodeServices( |
| 250 | readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { | |
| 251 | throw new Error("must not read Keychain for a custom endpoint"); | |
| 252 | }).pipe( | |
| 260 | readCursorUsageLimits({ apiEndpoint }, environment, true).pipe( | |
| 253 | 261 | Effect.provideService(HostProcessPlatform, "darwin"), |
| 254 | 262 | Effect.provideService( |
| 255 | 263 | HttpClient.HttpClient, |
packages/provider-cursor/src/server/usageLimits.ts+3-3| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | makeUnavailableUsageLimits, |
| 18 | 18 | makeUsageLimits, |
| 19 | 19 | } from "@t3tools/provider-core/server/usageLimits"; |
| 20 | import { readMacCursorAccessToken } from "./keychainToken.ts"; | |
| 20 | import * as CursorKeychain from "./CursorKeychain.ts"; | |
| 21 | 21 | |
| 22 | 22 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); |
| 23 | 23 | const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; |
| ... | ... | @@ -66,7 +66,6 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function |
| 66 | 66 | settings: Pick<CursorSettings, "apiEndpoint">, |
| 67 | 67 | environment: NodeJS.ProcessEnv = process.env, |
| 68 | 68 | allowKeychain = false, |
| 69 | keychainToken: () => Promise<string | null> = readMacCursorAccessToken, | |
| 70 | 69 | ) { |
| 71 | 70 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 72 | 71 | return yield* Effect.gen(function* () { |
| ... | ... | @@ -106,7 +105,8 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function |
| 106 | 105 | message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", |
| 107 | 106 | }); |
| 108 | 107 | } |
| 109 | token = (yield* Effect.tryPromise(keychainToken))?.trim(); | |
| 108 | const keychain = yield* CursorKeychain.CursorKeychain; | |
| 109 | token = (yield* keychain.accessToken)?.trim(); | |
| 110 | 110 | } else if (!token) { |
| 111 | 111 | const home = |
| 112 | 112 | (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); |