Skip to content

[Bug]: Usage counts one host Cursor CLI login and omits the accounts that Cursor instances bill #15906

Closed as duplicate

Description

@mInrOz

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.json from hostEnvironment (HOME, XDG_CONFIG_HOME, USERPROFILE, APPDATA, or the macOS Keychain). It does not read settings.providerInstances for the cursor driver. It calls readCursorAccountUsage one time and returns at most one cursor-account:<accountKey> source.
    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 mergeProviderInstanceEnvironment and add the default instance when providerInstances has no key for that driver.
    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_KEY in the merged instance environment or from a T3 sign-in that T3 stores per instance (cursor:<instanceId>). Every SDK session requires that key.
    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,

    Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, {
    ...sdkRunner,
    open: (input) =>
    auth.requireApiKey.pipe(
    Effect.flatMap((apiKey) =>
    Effect.acquireRelease(

    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.

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.
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.

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" });

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

  1. Sign in to the Cursor CLI on the server with account A, so ~/.config/cursor/auth.json (or the macOS Keychain) holds account A.
  2. In T3 Code, add a Cursor provider instance. Sign it in through T3 with account B, or set CURSOR_API_KEY for account B in the instance environment.
  3. Run a thread on that instance.
  4. 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

  1. razdgann commented on Oct 5, 2026

    @razdgann

    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/

  2. JMcCaslinDev commented on Oct 7, 2026

    @JMcCaslinDev

    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’s apiKey as CURSOR_API_KEY. When CURSOR_AUTH_TOKEN is unset, the reader treats that key as an API-key-only account and returns unsupported before trying the enabled Keychain login.

    My local fix passes the server’s original environment to readCursorUsageLimits instead 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?

  3. geanpeixoto commented on Oct 14, 2026

    @geanpeixoto

    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:

    • GetCurrentPeriodUsage with {} returned HTTP 200, but no planUsage. It contained only billingCycleStart, billingCycleEnd, and displayThreshold.
    • GetMe returned isEnterpriseUser: true and a teamId.
    • Repeating GetCurrentPeriodUsage with that teamId and includePooledUsage: true returned 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.

  4. abassaf commented on Nov 4, 2026

    @abassaf

    Note

    Written by claude-opus-5-5 on behalf of Anthony

    This 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 5 composer-2.5 turns there. The server's database holds all 5 as completed provider turns with complete turnTokenUsage: 197,798 input and 1,720 output tokens, each equal to the SDK's turn-ended usage. 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:

    • It's attributed to the instance that ran the turn. That avoids the host-login versus instance-account mismatch described here, and needs no new credentials.
    • It works without a CLI login, including API-key and T3 sign-in instances.
    • It's measured, not inferred: "complete", "partial" or "unavailable" per turn.

    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:

    1. Cursor rows come from T3's turn records only, and the Usage page says that history from outside T3 isn't included.
    2. Account history stays the source, and T3's records fill in when account history is unavailable or older than the window. This needs a rule against overlap.
    3. Per-instance coverage, as suggested above, with T3's records as the measured source for each instance.

    Which direction would you accept? I'm happy to implement it after a maintainer picks one.

  5. juliusmarminge commented on Nov 12, 2026

    @juliusmarminge
    Member

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions