Repository navigation
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Summary
Usage reads Cursor account history from one Cursor CLI login, which it finds through the host environment. Since orchestration V2, Cursor threads do not bill that login. Each Cursor provider instance bills the account of its own T3 sign-in or its own CURSOR_API_KEY. The Usage page does not count those accounts, and it shows no notice.
Code facts (main @ 37de6cbde65c7cf9ba90a2557c232e63b7e16988):
- The Cursor part of the Usage scan finds
auth.jsonfromhostEnvironment(HOME,XDG_CONFIG_HOME,USERPROFILE,APPDATA, or the macOS Keychain). It does not readsettings.providerInstancesfor thecursordriver. It callsreadCursorAccountUsageone time and returns at most onecursor-account:<accountKey>source.
t3code/apps/server/src/usage/UsageService.ts
Lines 676 to 754 in 37de6cb
const cursorUserHome = (platform === "win32" ? hostEnvironment["USERPROFILE"] : hostEnvironment["HOME"]) || home; const configHome = hostEnvironment["XDG_CONFIG_HOME"]?.trim(); const cursorHome = platform === "darwin" ? path.join(cursorUserHome, "Library", "Application Support") : platform === "win32" ? hostEnvironment["APPDATA"] || path.join(cursorUserHome, "AppData", "Roaming") : configHome && path.isAbsolute(configHome) ? configHome : path.join(cursorUserHome, ".config"); const cursorAuthPath = platform === "darwin" ? path.join(cursorUserHome, ".cursor", "auth.json") : path.join(cursorHome, platform === "win32" ? "Cursor" : "cursor", "auth.json"); const credentialStore = hostEnvironment["AGENT_CLI_CREDENTIAL_STORE"]; const loginUnavailable = Boolean(hostEnvironment["CURSOR_AUTH_TOKEN"]?.trim()) || Boolean(hostEnvironment["CURSOR_API_KEY"]?.trim()) || credentialStore === "memory"; if ( platform === "darwin" && credentialStore !== "file" && !loginUnavailable && !settings.cursorKeychainUsageEnabled ) { scanned.push({ provider: "cursor", dir: cursorAuthPath, volumeId: "", files: null, message: "Cursor account usage is off on this environment.", action: "enableCursorKeychain", }); return scanned; } const cursorUntilMs = yield* Clock.currentTimeMillis; const account = loginUnavailable ? { accountKey: null, records: [], missing: true, error: "Cursor account history needs a Cursor CLI login on this server.", } : yield* Effect.promise(() => readCursorAccountUsage( platform === "darwin" && credentialStore !== "file" ? { kind: "keychain" } : cursorAuthPath, windowStartMs, cursorUntilMs, ), ); // No saved login means there is no account source to report, not a setup error. if (account.missing && account.error === null) return scanned; if (account.accountKey !== null && account.error === null && !account.missing) { // The same account includes CLI and desktop history from every machine. // A stable remote fingerprint prevents connected environments counting it twice. const source = `cursor-account:${account.accountKey}`; scanned.push({ provider: "cursor", dir: source, hostId: "cursor.com", volumeId: account.accountKey, files: [{ path: source, records: account.records }], status: "ok", }); return scanned; } scanned.push({ provider: "cursor", dir: cursorAuthPath, volumeId: yield* Effect.promise(() => readDirectoryVolumeId(cursorAuthPath)), // Never combine a local fallback with another server's account-wide history. files: null, message: account.error ?? "Cursor account history needs a Cursor CLI login saved on this server.", }); return scanned; - The Claude, Codex, and Grok scans go per instance. They merge each instance environment with
mergeProviderInstanceEnvironmentand add the default instance whenproviderInstanceshas no key for that driver.
t3code/apps/server/src/usage/UsageService.ts
Lines 306 to 321 in 37de6cb
for (const driver of ["claudeAgent", "codex", "grok"] as const) { // Disabled accounts still have history. Explicit default slots replace // the legacy settings, just as they do in the provider registry. const instances: Array< Pick<ProviderInstanceConfig, "config" | "environment"> & { instanceId: ProviderInstanceId } > = Object.entries(settings.providerInstances) .filter(([, instance]) => instance.driver === driver) .map(([id, instance]) => ({ ...instance, instanceId: ProviderInstanceId.make(id) })); if (!Object.hasOwn(settings.providerInstances, driver)) { instances.push({ config: settings.providers[driver], instanceId: ProviderInstanceId.make(driver), }); } for (const instance of instances) { const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); - Each Cursor instance gets its key from
CURSOR_API_KEYin the merged instance environment or from a T3 sign-in that T3 stores per instance (cursor:<instanceId>). Every SDK session requires that key.
t3code/apps/server/src/provider/Drivers/CursorDriver.ts
Lines 86 to 123 in 37de6cb
const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, instanceId, }); const stampIdentity = withInstanceIdentity({ instanceId, driverKind: DRIVER_KIND, displayName: displayName ?? "Cursor", accentColor, continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies CursorSettings; const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( instanceId, path.join( (yield* ServerConfig.ServerConfig).stateDir, "provider-auth", encodeURIComponent(instanceId), "cursor.json", ), ).pipe( Effect.mapError( (cause) => new ProviderDriverError({ driver: DRIVER_KIND, instanceId, detail: "Could not open the Cursor credential store.", cause, }), ), ); const auth = yield* makeCursorAuth({ instanceId, displayName: displayName ?? "Cursor", enabled, ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}), store: credentials.store,
t3code/apps/server/src/provider/Drivers/CursorDriver.ts
Lines 158 to 163 in 37de6cb
Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { ...sdkRunner, open: (input) => auth.requireApiKey.pipe( Effect.flatMap((apiKey) => Effect.acquireRelease(
t3code/apps/server/src/provider/CursorAuth.ts
Lines 76 to 92 in 37de6cb
const readApiKey = Effect.gen(function* () { if (configuredKey) return configuredKey; const credentials = yield* Effect.tryPromise({ try: () => options.store.load(), catch: (cause) => new ProviderSetupError({ instanceId: options.instanceId, operation: "credentials", detail: "Could not read the Cursor sign-in. Try signing in again.", cause, }), }); const now = yield* Clock.currentTimeMillis; return credentials && (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now) ? credentials.apiKey : undefined;
So the account that Usage reads and the accounts that threads bill are not the same. This is true for a second instance, and it can also be true for the default instance.
What T3 stores for a sign-in: the stored credential has version, backendUrl, apiKey, createdAtMs, and optional apiKeyExpiresAtMs and email. It has no session access token and no user ID.
t3code/apps/server/src/provider/CursorCredentialStore.ts
Lines 10 to 19 in 37de6cb
| const Credentials = Schema.fromJsonString( | |
| Schema.Struct({ | |
| version: Schema.Literal(1), | |
| backendUrl: Schema.String, | |
| apiKey: Schema.String, | |
| createdAtMs: Schema.Finite, | |
| apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite), | |
| email: Schema.optionalKey(Schema.String), | |
| }), | |
| ); |
readCursorAccountUsage calls the dashboard endpoint with a session cookie. It builds the cookie and the account key from the sub claim of a CLI access token (a JWT). An SDK API key is not that token. So with what T3 stores today, Usage cannot reach the instance account through this endpoint. I do not know whether Cursor has a usage-history endpoint that accepts an SDK API key.t3code/apps/server/src/usage/cursorUsageReader.ts
Lines 108 to 148 in 37de6cb
| let accountKey: string | null = null; | |
| try { | |
| const payload = accessToken.split(".")[1]; | |
| const subject = object( | |
| JSON.parse(Buffer.from(payload ?? "", "base64url").toString("utf8")), | |
| ).sub; | |
| if (typeof subject !== "string" || !subject) throw new Error("Invalid authentication"); | |
| const userId = subject.split("|").at(-1); | |
| if (!userId) throw new Error("Invalid authentication"); | |
| accountKey = accountHash(subject); | |
| if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate) | |
| throw new Error("Invalid date window"); | |
| const deadline = AbortSignal.timeout(60_000); | |
| const records: UsageRecord[] = []; | |
| const occurrences = new Map<string, number>(); | |
| const pages: unknown[][] = []; | |
| let completed = false; | |
| const pageSize = 1000; | |
| let total: number | undefined; | |
| for (let page = 1; ; page++) { | |
| // A count can include overlapping page boundaries. Allow room to | |
| // reconcile them without imposing a fixed account-size limit. | |
| if (page > (total === undefined ? 1000 : Math.ceil(total / pageSize) * 2 + 1)) { | |
| throw new Error("Account usage page limit exceeded"); | |
| } | |
| const response = await request("https://cursor.com/api/dashboard/get-filtered-usage-events", { | |
| method: "POST", | |
| redirect: "error", | |
| signal: AbortSignal.any([deadline, AbortSignal.timeout(10_000)]), | |
| headers: { | |
| "Content-Type": "application/json", | |
| Origin: "https://cursor.com", | |
| Cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${userId}::${accessToken}`)}`, | |
| }, | |
| body: JSON.stringify({ | |
| page, | |
| pageSize, | |
| startDate: String(sinceMs), | |
| endDate: String(endDate), | |
| }), | |
| }); |
The Limits reader has the same gap. It accepts CURSOR_AUTH_TOKEN, then the macOS Keychain CLI login (when account usage is on), then the CLI auth.json access token. When CURSOR_API_KEY is set and CURSOR_AUTH_TOKEN is not, it returns unsupported. The driver passes the instance API key as CURSOR_API_KEY, so an instance with a T3 sign-in or an API key gets no limits unless CURSOR_AUTH_TOKEN is also set.
t3code/apps/server/src/provider/Layers/cursorUsageLimits.ts
Lines 78 to 125 in 37de6cb
| let token = environment.CURSOR_AUTH_TOKEN?.trim(); | |
| // An explicit API key can name a different account from the stored login. | |
| if (!token && environment.CURSOR_API_KEY?.trim()) { | |
| return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| } | |
| const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; | |
| if (!token && credentialStore === "memory") { | |
| return makeUnavailableUsageLimits({ | |
| checkedAt, | |
| reason: "unsupported", | |
| message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", | |
| }); | |
| } | |
| if (!token && platform === "darwin" && credentialStore !== "file") { | |
| if (!allowKeychain) { | |
| return makeUnavailableUsageLimits({ | |
| checkedAt, | |
| reason: "unsupported", | |
| message: "Enable Cursor account usage in T3 Code to read its Keychain login.", | |
| }); | |
| } | |
| if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { | |
| return makeUnavailableUsageLimits({ | |
| checkedAt, | |
| reason: "unsupported", | |
| message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", | |
| }); | |
| } | |
| token = (yield* Effect.tryPromise(keychainToken))?.trim(); | |
| } else if (!token) { | |
| const home = | |
| (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); | |
| const directory = | |
| platform === "win32" | |
| ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") | |
| : platform === "darwin" | |
| ? path.join(home, ".cursor") | |
| : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); | |
| const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( | |
| Effect.catchTags({ | |
| PlatformError: (error) => | |
| error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | |
| }), | |
| Effect.flatMap(decodeCredentials), | |
| ); | |
| token = credentials.accessToken?.trim(); | |
| } | |
| if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); |
t3code/apps/server/src/provider/Drivers/CursorDriver.ts
Lines 201 to 222 in 37de6cb
| const checkProvider = auth.readApiKey.pipe( | |
| Effect.orElseSucceed(() => undefined), | |
| Effect.flatMap((apiKey) => | |
| checkCursorProviderStatus( | |
| effectiveConfig, | |
| { | |
| ...processEnv, | |
| CURSOR_API_KEY: apiKey, | |
| }, | |
| auth.usesApiKey ? "api-key" : "browser", | |
| ).pipe( | |
| Effect.flatMap((snapshot) => | |
| effectiveConfig.enabled && | |
| snapshot.installed && | |
| snapshot.auth.status === "authenticated" | |
| ? serverSettings.getSettings.pipe( | |
| Effect.flatMap((settings) => | |
| readCursorUsageLimits( | |
| effectiveConfig, | |
| { ...processEnv, CURSOR_API_KEY: apiKey }, | |
| settings.cursorKeychainUsageEnabled, | |
| ), |
Observed on one host: the turn count of a second Cursor instance matched the event count of a different Cursor account, not the account that Usage reads.
Related: #15781 (Cursor V2 ignores the existing CLI login for auth). This issue is about Usage, not about sign-in.
Steps to reproduce
- Sign in to the Cursor CLI on the server with account A, so
~/.config/cursor/auth.json(or the macOS Keychain) holds account A. - In T3 Code, add a Cursor provider instance. Sign it in through T3 with account B, or set
CURSOR_API_KEYfor account B in the instance environment. - Run a thread on that instance.
- Open Usage > Cost or Usage > Tokens and select Cursor.
Expected behavior
The Cursor total includes the usage of every account that Cursor instances bill, with one count per account. If Usage cannot read an instance account, the page shows a notice for that instance and does not present the partial total as the full Cursor total.
Actual behavior
Usage shows only account A as the full Cursor total. It omits the cost and tokens of account B and shows no notice.
Possible directions
- Read history for each instance account, if Cursor has an endpoint that accepts the credential that T3 stores for that instance (the SDK API key). Count each account one time.
- If no such endpoint exists, show a notice per Cursor instance that the Usage scan cannot read.
Impact
Minor bug or occasional failure
Version or commit
0.0.46-nightly.20261004.2648; main @ 37de6cb
Environment
Linux, t3 serve --mode web, Cursor provider (V2 SDK) with more than one Cursor instance.
Activity
razdgann commentedon Oct 5, 2026
I'd make coverage an explicit part of the Usage result before trying to recover more credentials. Each configured Cursor instance could return readable, unsupported or failed, plus a verified billing-account identity when available. Aggregate account history once per verified account, while retaining the instance-to-account mapping; two instances on one account must not double the total, and an unreadable instance must not disappear from the coverage summary.
A useful regression matrix is A(host)/B(instance), B shared by two instances, and B unavailable. In the last case, label the displayed total as partial rather than zero usage for B. I wouldn't repurpose an SDK API key as a dashboard session credential without a documented contract. In ModelGate, the analogous distinction is request attribution versus whether usage was actually measured.
https://github.com/razdgann/modelgate-oss or https://modelgatehq.com/
JMcCaslinDev commentedon Oct 7, 2026
I hit the Cursor Limits issue on macOS after signing in to the Cursor provider through T3. The Cursor CLI login is available in macOS Keychain, and Cursor usage is enabled in T3.
In
CursorDriver, the Limits reader receives the provider instance’sapiKeyasCURSOR_API_KEY. WhenCURSOR_AUTH_TOKENis unset, the reader treats that key as an API-key-only account and returnsunsupportedbefore trying the enabled Keychain login.My local fix passes the server’s original environment to
readCursorUsageLimitsinstead of injecting the provider instance key. That lets the reader use the CLI Keychain token for Limits while keeping API-key accounts unsupported where appropriate. The focused Cursor tests pass with this change.Expected: with Cursor usage enabled, T3 reads the CLI login from Keychain and shows its Limits.
Actual: the instance API key prevents the reader from trying Keychain, so Cursor Limits are missing.
Could the Limits reader distinguish the provider’s SDK sign-in key from a Cursor account token, so one does not block the other?
fix(server): show Cursor limits after an in-app Cursor sign-in
geanpeixoto commentedon Oct 14, 2026
I reproduced the missing Cursor Limits on macOS with Nightly
0.0.46-nightly.20261014.3118, an existing Cursor CLI login in Keychain, and Cursor account usage enabled. In addition to the credential issue described here and in #19008, I found a second failure for an Enterprise account.Using the same CLI access token against
aiserver.v1.DashboardService:GetCurrentPeriodUsagewith{}returned HTTP 200, but noplanUsage. It contained onlybillingCycleStart,billingCycleEnd, anddisplayThreshold.GetMereturnedisEnterpriseUser: trueand ateamId.GetCurrentPeriodUsagewith thatteamIdandincludePooledUsage: truereturned all three budget percentages and a valid billing-cycle reset.Removing the driver's injected SDK key is therefore insufficient for this account: the unscoped response still becomes
unsupported, leaving Cursor absent from Limits.A local patch that preserves the credential checks and adds this Enterprise fallback returned all three monthly windows against the real account. All 14 focused driver/usage-limit tests passed, along with package typecheck, targeted lint, and formatting. The installed app remains unchanged.
This establishes that the CLI login's team-scoped budget is readable. It does not establish that the SDK bills the same account/team or resolve #16840. Credentials and account/team identifiers are omitted.
abassaf commentedon Nov 4, 2026
Note
Written by
claude-opus-5-5on behalf of AnthonyThis adds one more data point and a possible direction that doesn't depend on which Cursor credential Usage reads.
The gap from the T3 side. I ran an isolated dev server on #21540's head, with its own T3 home, an empty
HOME,AGENT_CLI_CREDENTIAL_STORE=file, and only a Cursor provider instance configured. I ran 5composer-2.5turns there. The server's database holds all 5 as completed provider turns withcompleteturnTokenUsage: 197,798 input and 1,720 output tokens, each equal to the SDK'sturn-endedusage. The Usage page on that server shows 0 tokens and 0 sessions, with no Cursor row. Cursor usage comes only from account history through a CLI login, and this server has none. When I checked my everyday install on 18 Oct, the CLI-login path showed no Cursor history after 13 Oct, although Cursor turns kept completing.A possible direction. Once #21540 lands, every Cursor turn T3 runs carries per-turn billed usage: input including cache, cached input, cache creation, output, and reasoning when reported. Usage could count Cursor from those records instead of, or as well as, the CLI login's account history. Three things favour it:
The trade-off is the one #10409 avoided: account history also includes usage from outside T3 (Cursor IDE, other machines), and combining the two sources risks double counting. Some options:
Which direction would you accept? I'm happy to implement it after a maintainer picks one.
juliusmarminge commentedon Nov 12, 2026
This is the same problem as #29419, so I'm closing it in favour of that one. If it is different, say how and it will be reopened.