diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 26d5dc4742f3..70af46ff6867 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -26,6 +26,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; +import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus, @@ -139,7 +140,15 @@ export const CursorDriver: ProviderDriver = { processEnv, discoverModels, ).pipe( + Effect.flatMap((snapshot) => + effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" + ? readCursorUsageLimits(effectiveConfig, processEnv).pipe( + Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), + ) + : Effect.succeed(snapshot), + ), Effect.map(stampIdentity), + Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 066d0a6b63a8..5f0cf4d90c71 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -19,6 +19,7 @@ import { enrichGrokSnapshot, } from "../Layers/GrokProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { readGrokUsageLimits } from "../Layers/grokUsageLimits.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -66,6 +67,8 @@ export const GrokDriver: ProviderDriver = { const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const serverSettings = yield* ServerSettingsService; const { cwd } = yield* ServerConfig; const eventLoggers = yield* ProviderEventLoggers; @@ -90,7 +93,17 @@ export const GrokDriver: ProviderDriver = { const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( + Effect.flatMap((snapshot) => + effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" + ? readGrokUsageLimits(processEnv).pipe( + Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), + ) + : Effect.succeed(snapshot), + ), Effect.map(stampIdentity), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Drivers/OpenCodeDriver.ts b/apps/server/src/provider/Drivers/OpenCodeDriver.ts index 7cb956aefb44..0d874b9ceba8 100644 --- a/apps/server/src/provider/Drivers/OpenCodeDriver.ts +++ b/apps/server/src/provider/Drivers/OpenCodeDriver.ts @@ -27,6 +27,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; +import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; import { checkOpenCodeProviderStatus, makePendingOpenCodeProvider, @@ -148,12 +149,22 @@ export const OpenCodeDriver: ProviderDriver Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), ); - const checkProvider = checkOpenCodeProviderStatus( - effectiveConfig, - serverConfig.cwd, - processEnv, + const checkProvider = Effect.all( + { + provider: checkOpenCodeProviderStatus(effectiveConfig, serverConfig.cwd, processEnv), + usageLimits: readOpenCodeGoUsageLimits({ + enabled: effectiveConfig.enabled, + serverUrl: effectiveConfig.serverUrl, + environment: processEnv, + }), + }, + { concurrency: "unbounded" }, ).pipe( + Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), Effect.map(stampIdentity), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, pathService), + Effect.provideService(HttpClient.HttpClient, httpClient), Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime, openCodeRuntime), ); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 937831cbd9e7..04e46af67830 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -35,6 +35,8 @@ import { } from "../Drivers/CursorSkills.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; const runNode = ( effect: Effect.Effect< @@ -952,3 +954,171 @@ describe("resolveCursorAcpConfigUpdates", () => { ]); }); }); + +describe("Cursor usage limits", () => { + const checkedAt = "2026-09-16T00:00:00.000Z"; + + it("uses the advertised percentages and billing-cycle reset", () => { + const limits = cursorUsageResponseToLimits( + { + billingCycleEnd: "1789876386000", + planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, + }, + checkedAt, + ); + expect(limits.windows).toEqual( + expect.arrayContaining([ + { + id: "totalPercentUsed", + kind: "monthly", + label: "Monthly", + usedPercent: 72.4, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + { + id: "autoPercentUsed", + kind: "monthly", + label: "Monthly · Auto", + usedPercent: 69.5, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + { + id: "apiPercentUsed", + kind: "monthly", + label: "Monthly · API", + usedPercent: 100, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + ]), + ); + }); + + it("does not invent unused allowance for absent buckets", () => { + expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( + "unsupported", + ); + expect( + cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]); + expect( + cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]); + }); + + it("reads the instance's credentials and endpoint even when usage enabled is false", async () => { + await runNode( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(directory, "cursor")); + yield* fs.writeFileString( + path.join(directory, "cursor", "auth.json"), + '{"accessToken":"instance-token"}', + ); + const client = HttpClient.make((request) => { + expect(request.url).toBe( + "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + ); + expect(request.method).toBe("POST"); + expect(request.headers.authorization).toBe("Bearer instance-token"); + expect(request.headers["connect-protocol-version"]).toBe("1"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), + ), + ); + }); + yield* fs.makeDirectory(path.join(directory, ".cursor")); + yield* fs.writeFileString( + path.join(directory, ".cursor", "auth.json"), + '{"accessToken":"instance-token"}', + ); + for (const platform of ["linux", "darwin"] as const) { + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "https://cursor.example/" }, + { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, + ).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + } + }).pipe(Effect.scoped), + ); + }); + + it("never reads stale files for keychain or memory logins, but accepts an explicit auth token", async () => { + for (const platform of ["linux", "darwin"] as const) { + for (const token of [undefined, "explicit-token"]) { + const limits = await runNode( + readCursorUsageLimits( + { apiEndpoint: "" }, + { + AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", + ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), + }, + ).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read an unrelated credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(token).toBe("explicit-token"); + expect(request.headers.authorization).toBe("Bearer explicit-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 10 } }), + ), + ); + }), + ), + ), + ); + if (token) expect(limits.windows[0]?.usedPercent).toBe(10); + else expect(limits.unavailable?.reason).toBe("unsupported"); + } + } + }); + + it("reports failed requests without exposing credentials or response bodies", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("private response", { status: 401 }), + ), + ), + ), + ), + ), + ); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }); + }); + + it("does not use a stored login for an explicit API key", async () => { + const limits = await runNode( + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request usage")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + }); +}); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 127295af5e41..495feaf8d61b 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { GrokSettings } from "@t3tools/contracts"; import { @@ -18,6 +19,7 @@ import { parseGrokModelsCliOutput, } from "./GrokProvider.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { grokUsageResponseToLimits, readGrokUsageLimits } from "./grokUsageLimits.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -530,3 +532,252 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { }), ); }); + +describe("Grok usage limits", () => { + const checkedAt = "2026-09-16T00:00:00.000Z"; + + it("uses the reported subscription percentage and weekly reset", () => { + const limits = grokUsageResponseToLimits( + { + config: { + creditUsagePercent: 100, + currentPeriod: { + type: "USAGE_PERIOD_TYPE_WEEKLY", + end: "2026-09-18T03:10:30.159171+00:00", + }, + }, + }, + checkedAt, + ); + expect(limits.windows).toEqual([ + { + id: "subscription", + kind: "weekly", + label: "Weekly", + usedPercent: 100, + resetsAt: "2026-09-18T03:10:30.159Z", + }, + ]); + }); + + it("does not invent allowance or reset dates when billing omits them", () => { + for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { + const limits = grokUsageResponseToLimits(response, checkedAt); + expect(limits.windows).toEqual([]); + expect(limits.unavailable?.reason).toBe("unsupported"); + } + expect( + grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, + ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]); + expect( + grokUsageResponseToLimits( + { + config: { + creditUsagePercent: 120, + currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" }, + }, + }, + checkedAt, + ).windows, + ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); + }); +}); + +it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { + it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString( + NodePath.join(directory, "auth.json"), + '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', + ); + const limits = yield* readGrokUsageLimits({ + GROK_HOME: directory, + HOME: "/unrelated-home", + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.method).toBe("GET"); + expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); + expect(request.headers.authorization).toBe("Bearer session-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: 37 } }), + ), + ); + }), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(37); + }).pipe(Effect.scoped), + ); + + it.effect( + "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", + () => + readGrokUsageLimits({ + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: (path) => + path.endsWith("auth.json") + ? Effect.die("must not read stored credentials") + : Effect.succeed(""), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer legacy-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: 12 } }), + ), + ); + }), + ), + Effect.tap((limits) => Effect.sync(() => expect(limits.windows[0]?.usedPercent).toBe(12))), + ), + ); + + it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () => + Effect.gen(function* () { + for (const environment of [ + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + XAI_API_KEY: "different-api-account", + }, + { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' }, + { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' }, + { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', + GROK_OIDC_ISSUER: "https://custom.example", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + GROK_MODELS_BASE_URL: "https://custom.example/v1", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + GROK_OAUTH2_PRINCIPAL_TYPE: "Team", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', + GROK_OAUTH2_PRINCIPAL_ID: "team-id", + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', + GROK_CONFIG: '{"auth_provider_command":"custom-auth"}', + }, + { + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', + GROK_CONFIG_PATH: "/custom-config.toml", + }, + ]) { + const limits = yield* readGrokUsageLimits({ + HOME: "/definitely/not/a/grok-home", + ...environment, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request another account's quota")), + ), + ); + expect(limits.windows).toEqual([]); + expect(limits.unavailable?.reason).toBe("unsupported"); + } + }), + ); + + it.effect( + "reports a missing login as unsupported and malformed credentials as a sanitized failure", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped(); + const client = HttpClient.make(() => Effect.die("must not request without credentials")); + const missing = yield* readGrokUsageLimits({ HOME: directory }).pipe( + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(missing.unavailable?.reason).toBe("unsupported"); + for (const contents of [ + "private-token-invalid-json", + '{"https://accounts.x.ai/sign-in":{"key":42}}', + ]) { + const malformed = yield* readGrokUsageLimits({ + GROK_HOME: directory, + GROK_AUTH: contents, + }).pipe(Effect.provideService(HttpClient.HttpClient, client)); + expect(malformed.unavailable).toEqual({ + reason: "probeFailed", + message: "Grok could not read usage limits.", + }); + } + }).pipe(Effect.scoped), + ); + + it.effect( + "does not request subscription limits for custom account or endpoint configuration", + () => + Effect.gen(function* () { + for (const config of [ + '[auth]\nprovider_command = "custom-auth"', + '[grok_com_config]\nissuer = "https://custom.example"', + 'endpoints.proxy = "https://custom.example"', + ]) { + const limits = yield* readGrokUsageLimits({ + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: (path) => { + expect(path.endsWith("config.toml")).toBe(true); + return Effect.succeed(config); + }, + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request another account's quota")), + ), + ); + expect(limits.windows).toEqual([]); + expect(limits.unavailable?.reason).toBe("unsupported"); + } + }), + ); + + it.effect("sanitizes HTTP failures and malformed billing responses", () => + Effect.gen(function* () { + for (const response of [ + new Response("private response", { status: 401 }), + Response.json({ config: { creditUsagePercent: "private-value" } }), + ]) { + const limits = yield* readGrokUsageLimits({ + HOME: "/definitely/not/a/grok-home", + GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"private-token"}}', + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, response)), + ), + ), + ); + expect(limits.windows).toEqual([]); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Grok could not read usage limits.", + }); + } + }), + ); +}); diff --git a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts index ec00d0399d39..b50a51f6cb31 100644 --- a/apps/server/src/provider/Layers/OpenCodeProvider.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeProvider.test.ts @@ -4,9 +4,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { beforeEach } from "vite-plus/test"; import { OpenCodeSettings } from "@t3tools/contracts"; @@ -23,10 +25,125 @@ import { openCodeCommandsToServerProviderSlashCommands, } from "./OpenCodeProvider.ts"; import type { OpenCodeInventory } from "../opencodeRuntime.ts"; +import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; +it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => + Effect.gen(function* () { + const resetsAt = "2026-09-17T12:00:00.000Z"; + const limits = yield* readOpenCodeGoUsageLimits({ + enabled: true, + serverUrl: "", + environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: (path) => { + NodeAssert.equal(path, "/instance/data/opencode/auth.json"); + return Effect.succeed( + JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), + ); + }, + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); + NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + usage: { + rolling: { percent: 0, resetsAt }, + weekly: { percent: 10, resetsAt }, + monthly: { percent: 125, resetsAt }, + }, + }), + ), + ); + }), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable, undefined); + NodeAssert.deepEqual( + limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ + kind, + usedPercent, + reset, + })), + [ + { kind: "session", usedPercent: 0, reset: resetsAt }, + { kind: "weekly", usedPercent: 10, reset: resetsAt }, + { kind: "monthly", usedPercent: 100, reset: resetsAt }, + ], + ); + }), +); + +it.effect("does not read local credentials for external or disabled OpenCode instances", () => + Effect.gen(function* () { + for (const settings of [ + { enabled: true, serverUrl: "https://remote.example" }, + { enabled: false, serverUrl: "" }, + ]) { + const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("unexpected credential read"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("unexpected usage request")), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable?.reason, "unsupported"); + } + }), +); + +it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => + Effect.gen(function* () { + for (const [status, reason] of [ + [403, "unsupported"], + [401, "probeFailed"], + [200, "probeFailed"], + ] as const) { + const limits = yield* readOpenCodeGoUsageLimits({ + enabled: true, + serverUrl: "", + environment: { + OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', + }, + }).pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("inline credentials must bypass disk"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), + ), + ), + Effect.provide(NodeServices.layer), + ); + NodeAssert.equal(limits.unavailable?.reason, reason); + NodeAssert.deepEqual(limits.windows, []); + } + }), +); + /** * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag * are deleted. The snapshot-producing logic they wrapped now lives in the diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts new file mode 100644 index 000000000000..685378b9d79e --- /dev/null +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -0,0 +1,140 @@ +import * as NodeOS from "node:os"; +import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "../providerUsageLimits.ts"; + +const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); +const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); +const CursorUsageResponse = Schema.Struct({ + billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + planUsage: Schema.optional( + Schema.Struct({ + totalPercentUsed: Schema.optional(Schema.Number), + autoPercentUsed: Schema.optional(Schema.Number), + apiPercentUsed: Schema.optional(Schema.Number), + }), + ), +}); + +/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ +export function cursorUsageResponseToLimits( + response: typeof CursorUsageResponse.Type, + checkedAt: string, +) { + const reset = DateTime.make(Number(response.billingCycleEnd)); + const resetsAt = + Number(response.billingCycleEnd) > 0 && Option.isSome(reset) + ? DateTime.formatIso(reset.value) + : undefined; + const windows: ServerProviderUsageWindow[] = []; + if (response.planUsage) { + for (const [key, label] of [ + ["totalPercentUsed", "Monthly"], + ["autoPercentUsed", "Monthly · Auto"], + ["apiPercentUsed", "Monthly · API"], + ] as const) { + const usedPercent = response.planUsage[key]; + if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; + windows.push({ + id: key, + kind: "monthly", + label, + usedPercent: clampPercent(usedPercent), + ...(resetsAt ? { resetsAt } : {}), + }); + } + } + return windows.length > 0 + ? makeUsageLimits({ checkedAt, windows }) + : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); +} + +export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( + settings: Pick, + environment: NodeJS.ProcessEnv = process.env, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + 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" || (platform === "darwin" && credentialStore !== "file")) + ) { + // Cursor's default macOS login lives in the keychain; a leftover file may be another account. + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor usage requires a file-based login or CURSOR_AUTH_TOKEN.", + }); + } + 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 client = yield* HttpClient.HttpClient; + const endpoint = ( + settings.apiEndpoint.trim() || + environment.CURSOR_API_ENDPOINT?.trim() || + "https://api2.cursor.sh" + ).replace(/\/$/, ""); + const response = yield* client.execute( + HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeaders({ + "connect-protocol-version": "1", + "x-cursor-client-type": "cli", + }), + HttpClientRequest.bodyJsonUnsafe({}), + ), + ); + const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return cursorUsageResponseToLimits(body, checkedAt); + }).pipe( + Effect.timeout("10 seconds"), + Effect.catch(() => + Effect.succeed( + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/grokUsageLimits.ts b/apps/server/src/provider/Layers/grokUsageLimits.ts new file mode 100644 index 000000000000..8c3db6bea80b --- /dev/null +++ b/apps/server/src/provider/Layers/grokUsageLimits.ts @@ -0,0 +1,148 @@ +import * as NodeOS from "node:os"; +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "../providerUsageLimits.ts"; + +const GrokCredentials = Schema.Record( + Schema.String, + Schema.Struct({ + key: Schema.optional(Schema.String), + auth_mode: Schema.optional(Schema.String), + }), +); +const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); +const GrokUsageResponse = Schema.Struct({ + config: Schema.optional( + Schema.Struct({ + creditUsagePercent: Schema.optional(Schema.Number), + currentPeriod: Schema.optional( + Schema.Struct({ + type: Schema.optional(Schema.String), + end: Schema.optional(Schema.String), + }), + ), + }), + ), +}); + +export function grokUsageResponseToLimits( + response: typeof GrokUsageResponse.Type, + checkedAt: string, +) { + const usedPercent = response.config?.creditUsagePercent; + if (usedPercent === undefined || !Number.isFinite(usedPercent)) { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + const period = response.config?.currentPeriod; + const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); + const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; + const reset = period?.end ? DateTime.make(period.end) : Option.none(); + const window: ServerProviderUsageWindow = { + id: "subscription", + kind, + label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", + usedPercent: clampPercent(usedPercent), + ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), + }; + return makeUsageLimits({ checkedAt, windows: [window] }); +} + +export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( + environment: NodeJS.ProcessEnv = process.env, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return yield* Effect.gen(function* () { + // T3's ACP adapter explicitly selects API-key auth when this variable is set. + if (environment.XAI_API_KEY?.trim()) { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + // Alternate auth deployments can select another scope or account from the same file. + if ( + [ + "GROK_OIDC_ISSUER", + "GROK_OIDC_CLIENT_ID", + "GROK_OAUTH2_ISSUER", + "GROK_OAUTH2_CLIENT_ID", + "GROK_OAUTH2_PRINCIPAL_TYPE", + "GROK_OAUTH2_PRINCIPAL_ID", + "GROK_AUTH_PROVIDER_COMMAND", + "GROK_LOCAL_AUTH", + "GROK_CLI_CHAT_PROXY_BASE_URL", + "GROK_MODELS_BASE_URL", + "GROK_CONFIG", + "GROK_CONFIG_PATH", + ].some((name) => environment[name]?.trim()) + ) { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.GROK_HOME?.trim() || + path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); + for (const configPath of [ + path.join(home, "config.toml"), + path.join(home, "managed_config.toml"), + path.join(home, "requirements.toml"), + "/etc/grok/managed_config.toml", + "/etc/grok/requirements.toml", + ]) { + const config = yield* fs.readFileString(configPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), + }), + ); + // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. + if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + } + const contents = + environment.GROK_AUTH?.trim() || + (yield* fs.readFileString(path.join(home, "auth.json")).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const credentials = yield* decodeCredentials(contents); + // Never pick an arbitrary account from other deployments stored in the same file. + const credential = + credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? + credentials["https://accounts.x.ai/sign-in"]; + const token = credential?.auth_mode === "api_key" ? undefined : credential?.key?.trim(); + if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( + HttpClientRequest.bearerToken(token), + ), + ); + const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return grokUsageResponseToLimits(body, checkedAt); + }).pipe( + Effect.timeout("10 seconds"), + Effect.catch(() => + Effect.succeed( + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Grok could not read usage limits.", + }), + ), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/openCodeUsageLimits.ts b/apps/server/src/provider/Layers/openCodeUsageLimits.ts new file mode 100644 index 000000000000..22b542142cc4 --- /dev/null +++ b/apps/server/src/provider/Layers/openCodeUsageLimits.ts @@ -0,0 +1,109 @@ +import * as NodeOS from "node:os"; + +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "../providerUsageLimits.ts"; + +const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); +const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); +const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); +const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); +const UsageWindow = Schema.Struct({ + percent: Schema.Finite, + resetsAt: Schema.DateTimeUtcFromString, +}); +const UsageResponse = Schema.Struct({ + usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), +}); + +/** External OpenCode servers own their credentials; never read the host's account for them. */ +export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { + readonly enabled: boolean; + readonly serverUrl: string; + readonly environment: NodeJS.ProcessEnv; +}) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + if (!input.enabled || input.serverUrl.trim()) return unsupported; + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const env = input.environment; + const dataHome = + env.XDG_DATA_HOME || + path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); + const authPath = path.join(dataHome, "opencode", "auth.json"); + const contents = + env.OPENCODE_AUTH_CONTENT || + (yield* fs.readFileString(authPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const auth = yield* decodeAuthFile(contents); + const apiAuth = decodeApiAuth(auth["opencode-go"]); + // OpenCode overlays stored API credentials after environment credentials. + const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); + if (!apiKey) return unsupported; + + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( + HttpClientRequest.bearerToken(apiKey), + ), + ); + // A valid Zen key can exist without a Go subscription. + if (response.status === 403) return unsupported; + const body = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), + ); + const windows: ServerProviderUsageWindow[] = [ + { + id: "go_rolling", + kind: "session", + label: "Go · Session", + windowDurationMins: 5 * 60, + usedPercent: clampPercent(body.usage.rolling.percent), + resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), + }, + { + id: "go_weekly", + kind: "weekly", + label: "Go · Weekly", + windowDurationMins: 7 * 24 * 60, + usedPercent: clampPercent(body.usage.weekly.percent), + resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), + }, + { + id: "go_monthly", + kind: "monthly", + label: "Go · Monthly", + usedPercent: clampPercent(body.usage.monthly.percent), + resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), + }, + ]; + return makeUsageLimits({ checkedAt, windows }); + }).pipe( + Effect.timeout("5 seconds"), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "OpenCode Go could not read usage.", + }), + ), + ); +}); diff --git a/docs/user/usage.md b/docs/user/usage.md index dbef802509b3..2e6b4acd9e4c 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -69,6 +69,18 @@ current model's limits without leaving the conversation. The result opens above closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh anything. The command is offered only for providers that appear under **Usage → Limits**. +OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in +the environment. T3 cannot report limits for external OpenCode servers because their credentials +belong to the remote server. Cursor reports +its monthly allowance, including separate Auto and API usage, using a file-based CLI login or +`CURSOR_AUTH_TOKEN`. Cursor's default macOS keychain login does not currently report limits. +On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment +to use a file-based login. + +Grok reports the remaining subscription allowance and reset time for its current billing period +after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication +or endpoint configurations do not report subscription limits. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`.