Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion apps/server/src/usage/UsageService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ function makeFakeCursor() {
accountKey: "account-a",
events: [] as { readonly timestampMs: number; readonly outputTokens: number }[],
error: null as string | null,
usageSinceMs: undefined as number | undefined,
gate: undefined as Deferred.Deferred<void> | undefined,
calls: [] as { readonly sinceMs: number; readonly untilMs: number }[],
credentials: [] as unknown[],
Expand Down Expand Up @@ -320,7 +321,14 @@ function makeFakeCursor() {
dedupeKey: `cursor-account:${accountKey}:${key}:${occurrence}`,
};
});
return { accountKey, records, missing: false, error: null };
const { usageSinceMs } = state;
return {
accountKey,
records,
missing: false,
error: null,
...(usageSinceMs === undefined ? {} : { usageSinceMs }),
};
});
return { state, read };
}
Expand Down Expand Up @@ -1576,6 +1584,25 @@ describe("UsageService", () => {
assert.deepStrictEqual(after.state.calls, [
{ sinceMs: CURSOR_NOW - HOUR_MS, untilMs: CURSOR_NOW + 2 * 60 * 1000 },
]);

// Cursor reports usage since a cycle start that its usage events never reach.
after.state.usageSinceMs = CURSOR_NOW - 2 * HOUR_MS;
yield* TestClock.adjust(Duration.minutes(2));
const stale = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true });
assert.deepStrictEqual(stale.buckets, original.buckets);
assert.strictEqual(cursorSource(stale)?.status, "partial");
assert.strictEqual(
cursorSource(stale)?.message,
"Cursor has not reported your recent usage history.",
);

after.state.events = [
...before.state.events,
{ timestampMs: CURSOR_NOW + 3 * 60 * 1000, outputTokens: 2 },
];
yield* TestClock.adjust(Duration.minutes(2));
const caughtUp = yield* restarted.readSummary({ ...WINDOW, awaitRefresh: true });
assert.strictEqual(cursorSource(caughtUp)?.status, "ok");
}).pipe(
Effect.provide(
layerService({
Expand Down
12 changes: 11 additions & 1 deletion packages/provider-cursor/src/server/CursorUsageAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import * as CursorAccountReader from "./CursorAccountReader.ts";
import { readStoredCursorApiKey } from "./credentialStore.ts";

const CURSOR_ACCOUNT_READ_ERROR = "Cursor account usage could not be read.";
const CURSOR_ACCOUNT_STALE_ERROR = "Cursor has not reported your recent usage history.";

const decodeCacheFile = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Unknown as unknown as Schema.Codec<unknown>),
Expand Down Expand Up @@ -147,7 +148,16 @@ const make = Effect.gen(function* () {
retentionStartMs,
);
caches.set(credentialKey, merged.cache);
failures.delete(credentialKey);
const { usageSinceMs } = result;
// Cursor's usage-events RPC can stop returning recent events while the cycle shows usage.
if (
usageSinceMs !== undefined &&
!merged.cache.records.some((record) => record.timestampMs >= usageSinceMs)
) {
failures.set(credentialKey, { atMs: nowMs, error: CURSOR_ACCOUNT_STALE_ERROR });
} else {
failures.delete(credentialKey);
}
if (merged.changed) cacheDirty = true;
yield* schedulePersist;
}, refreshLock.withPermit);
Expand Down
32 changes: 30 additions & 2 deletions packages/provider-cursor/src/server/accountUsage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ interface DashboardRequest {
readonly headers: Headers;
}

const dashboard = (answer: (request: DashboardRequest) => Response | Promise<Response>) =>
const dashboard = (
answer: (request: DashboardRequest) => Response | Promise<Response>,
periodUsage: unknown = {},
) =>
HttpClient.make((request, url, signal, fiber) =>
Effect.tryPromise({
try: async () => {
Expand All @@ -36,6 +39,9 @@ const dashboard = (answer: (request: DashboardRequest) => Response | Promise<Res
Response.json({ accessToken: request.headers.authorization.slice(7) }),
);
}
if (url.pathname.endsWith("/GetCurrentPeriodUsage")) {
return HttpClientResponse.fromWeb(request, Response.json(periodUsage));
}
const response = await answer({
url: url.toString(),
init: { ...init, signal, body },
Expand All @@ -56,9 +62,10 @@ const readAccountUsage = (
sinceMs: number,
endDate: number,
answer: (request: DashboardRequest) => Response | Promise<Response>,
periodUsage?: unknown,
) =>
readCursorAccountUsage(credentialSource, sinceMs, endDate).pipe(
Effect.provideService(HttpClient.HttpClient, dashboard(answer)),
Effect.provideService(HttpClient.HttpClient, dashboard(answer, periodUsage)),
);

describe("Cursor account history", () => {
Expand Down Expand Up @@ -157,6 +164,27 @@ describe("Cursor account history", () => {
assert.isNull(result.error);
assert.deepStrictEqual(result.records, []);
assert.isFalse(result.missing);
assert.isUndefined(result.usageSinceMs);
}
// The current billing cycle tells whether an empty history hides recent usage.
for (const [periodUsage, usageSinceMs] of [
[
{ billingCycleStart: "1780000000000", planUsage: { totalPercentUsed: 2.9 } },
1780000000000,
],
[{ billingCycleStart: "1780000000000", planUsage: { totalPercentUsed: 0 } }, undefined],
[{ billingCycleStart: "1780000000000" }, undefined],
[{ error: "upstream error" }, undefined],
] as const) {
const result = yield* readAccountUsage(
credential,
0,
1781000000000,
async () => Response.json({}),
periodUsage,
);
assert.isNull(result.error);
assert.strictEqual(result.usageSinceMs, usageSinceMs);
}
for (const body of [
{ error: "upstream error" },
Expand Down
38 changes: 38 additions & 0 deletions packages/provider-cursor/src/server/accountUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import * as Fiber from "effect/Fiber";
import * as FetchHttpClient from "effect/http/FetchHttpClient";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";

import type { CursorCredentialSource } from "./accountCache.ts";
import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts";
import { CursorUsageResponse } from "./usageLimits.ts";

function object(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
Expand All @@ -36,6 +38,8 @@ export interface CursorAccountUsageReadResult {
readonly records: readonly UsageRecord[];
readonly missing: boolean;
readonly error: string | null;
/** Start of the billing cycle in which Cursor reports usage; its history should reach it. */
readonly usageSinceMs?: number;
}

class CursorAccountUsageInvalidError extends Schema.TaggedError<CursorAccountUsageInvalidError>()(
Expand Down Expand Up @@ -112,6 +116,38 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi
accountKey = yield* accountHash(subject);
if (!Number.isFinite(sinceMs) || !Number.isFinite(endDate) || sinceMs < 0 || sinceMs > endDate)
return yield* invalid("Invalid date window");
const periodUsage = yield* httpClient
.execute(
HttpClientRequest.post(
`${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`,
).pipe(
HttpClientRequest.bearerToken(accessToken),
HttpClientRequest.setHeaders({
"connect-protocol-version": "1",
"x-cursor-client-type": "cli",
}),
HttpClientRequest.bodyJsonUnsafe({}),
),
)
.pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.flatMap(HttpClientResponse.schemaBodyJson(CursorUsageResponse)),
Effect.map(({ billingCycleStart, planUsage }) => {
const cycleStartMs = Number(billingCycleStart);
const used = [
planUsage?.totalPercentUsed,
planUsage?.autoPercentUsed,
planUsage?.apiPercentUsed,
].some((percent) => percent !== undefined && percent > 0);
return used && Number.isFinite(cycleStartMs) && cycleStartMs > 0
? cycleStartMs
: undefined;
}),
Effect.provideService(FetchHttpClient.RequestInit, NO_REDIRECT),
Effect.timeout(Duration.seconds(10)),
Effect.orElseSucceed(() => undefined),
Effect.forkScoped,
);
const pageRequest = HttpClientRequest.post(
`${DEFAULT_CURSOR_API_ENDPOINT}/aiserver.v1.DashboardService/GetFilteredUsageEvents`,
).pipe(
Expand Down Expand Up @@ -310,11 +346,13 @@ export const readCursorAccountUsage = Effect.fn("readCursorAccountUsage")(functi
"cursor.usage.events": rawCount,
"cursor.usage.records": records.length,
});
const usageSinceMs = yield* Fiber.join(periodUsage);
return {
accountKey,
records,
missing: false,
error: null,
...(usageSinceMs === undefined ? {} : { usageSinceMs }),
} satisfies CursorAccountUsageReadResult;
});
return yield* read.pipe(
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-cursor/src/server/usageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "@t3tools/provider-core/server/usageLimits";
import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts";

const CursorUsageResponse = Schema.Struct({
export const CursorUsageResponse = Schema.Struct({
billingCycleStart: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
planUsage: Schema.optional(
Expand Down
Loading