| author | |
| committer | |
| log | eff44be433dc9683da7764a20334598e47b127b2 |
| tree | 9a82b6180a6ce96759e5501fa03c0b371d7ada50 |
| parent | 052c7ae53e46c705d5fd5dfa995275bc3100744e |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>10 files changed, 984 insertions(+), 4 deletions(-)
apps/server/src/provider/Drivers/CursorDriver.ts+9| ... | @@ -26,6 +26,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; | ... | @@ -26,6 +26,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; |
| 26 | import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; | 26 | import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; |
| 27 | import { ProviderDriverError } from "../Errors.ts"; | 27 | import { ProviderDriverError } from "../Errors.ts"; |
| 28 | import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; | 28 | import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; |
| 29 | import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts"; | ||
| 29 | import { | 30 | import { |
| 30 | buildInitialCursorProviderSnapshot, | 31 | buildInitialCursorProviderSnapshot, |
| 31 | checkCursorProviderStatus, | 32 | checkCursorProviderStatus, |
| ... | @@ -139,7 +140,15 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -139,7 +140,15 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 139 | processEnv, | 140 | processEnv, |
| 140 | discoverModels, | 141 | discoverModels, |
| 141 | ).pipe( | 142 | ).pipe( |
| 143 | Effect.flatMap((snapshot) => | ||
| 144 | effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" | ||
| 145 | ? readCursorUsageLimits(effectiveConfig, processEnv).pipe( | ||
| 146 | Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), | ||
| 147 | ) | ||
| 148 | : Effect.succeed(snapshot), | ||
| 149 | ), | ||
| 142 | Effect.map(stampIdentity), | 150 | Effect.map(stampIdentity), |
| 151 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 143 | Effect.provideService(Crypto.Crypto, crypto), | 152 | Effect.provideService(Crypto.Crypto, crypto), |
| 144 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 153 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 145 | Effect.provideService(FileSystem.FileSystem, fileSystem), | 154 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
apps/server/src/provider/Drivers/GrokDriver.ts+13| ... | @@ -19,6 +19,7 @@ import { | ... | @@ -19,6 +19,7 @@ import { |
| 19 | enrichGrokSnapshot, | 19 | enrichGrokSnapshot, |
| 20 | } from "../Layers/GrokProvider.ts"; | 20 | } from "../Layers/GrokProvider.ts"; |
| 21 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | 21 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 22 | import { readGrokUsageLimits } from "../Layers/grokUsageLimits.ts"; | ||
| 22 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; | 23 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 23 | import { | 24 | import { |
| 24 | defaultProviderContinuationIdentity, | 25 | defaultProviderContinuationIdentity, |
| ... | @@ -66,6 +67,8 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ... | @@ -66,6 +67,8 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 66 | const crypto = yield* Crypto.Crypto; | 67 | const crypto = yield* Crypto.Crypto; |
| 67 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; | 68 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 68 | const httpClient = yield* HttpClient.HttpClient; | 69 | const httpClient = yield* HttpClient.HttpClient; |
| 70 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 71 | const path = yield* Path.Path; | ||
| 69 | const serverSettings = yield* ServerSettingsService; | 72 | const serverSettings = yield* ServerSettingsService; |
| 70 | const { cwd } = yield* ServerConfig; | 73 | const { cwd } = yield* ServerConfig; |
| 71 | const eventLoggers = yield* ProviderEventLoggers; | 74 | const eventLoggers = yield* ProviderEventLoggers; |
| ... | @@ -90,7 +93,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { | ... | @@ -90,7 +93,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 90 | const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); | 93 | const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); |
| 91 | 94 | ||
| 92 | const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( | 95 | const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( |
| 96 | Effect.flatMap((snapshot) => | ||
| 97 | effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" | ||
| 98 | ? readGrokUsageLimits(processEnv).pipe( | ||
| 99 | Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), | ||
| 100 | ) | ||
| 101 | : Effect.succeed(snapshot), | ||
| 102 | ), | ||
| 93 | Effect.map(stampIdentity), | 103 | Effect.map(stampIdentity), |
| 104 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 105 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 106 | Effect.provideService(Path.Path, path), | ||
| 94 | Effect.provideService(Crypto.Crypto, crypto), | 107 | Effect.provideService(Crypto.Crypto, crypto), |
| 95 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), | 108 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 96 | ); | 109 | ); |
apps/server/src/provider/Drivers/OpenCodeDriver.ts+15-4| ... | @@ -27,6 +27,7 @@ import { ServerConfig } from "../../config.ts"; | ... | @@ -27,6 +27,7 @@ import { ServerConfig } from "../../config.ts"; |
| 27 | import { ServerSettingsService } from "../../serverSettings.ts"; | 27 | import { ServerSettingsService } from "../../serverSettings.ts"; |
| 28 | import { ProviderDriverError } from "../Errors.ts"; | 28 | import { ProviderDriverError } from "../Errors.ts"; |
| 29 | import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; | 29 | import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts"; |
| 30 | import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts"; | ||
| 30 | import { | 31 | import { |
| 31 | checkOpenCodeProviderStatus, | 32 | checkOpenCodeProviderStatus, |
| 32 | makePendingOpenCodeProvider, | 33 | makePendingOpenCodeProvider, |
| ... | @@ -148,12 +149,22 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> | ... | @@ -148,12 +149,22 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv> |
| 148 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | 149 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), |
| 149 | ); | 150 | ); |
| 150 | 151 | ||
| 151 | const checkProvider = checkOpenCodeProviderStatus( | 152 | const checkProvider = Effect.all( |
| 152 | effectiveConfig, | 153 | { |
| 153 | serverConfig.cwd, | 154 | provider: checkOpenCodeProviderStatus(effectiveConfig, serverConfig.cwd, processEnv), |
| 154 | processEnv, | 155 | usageLimits: readOpenCodeGoUsageLimits({ |
| 156 | enabled: effectiveConfig.enabled, | ||
| 157 | serverUrl: effectiveConfig.serverUrl, | ||
| 158 | environment: processEnv, | ||
| 159 | }), | ||
| 160 | }, | ||
| 161 | { concurrency: "unbounded" }, | ||
| 155 | ).pipe( | 162 | ).pipe( |
| 163 | Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), | ||
| 156 | Effect.map(stampIdentity), | 164 | Effect.map(stampIdentity), |
| 165 | Effect.provideService(FileSystem.FileSystem, fileSystem), | ||
| 166 | Effect.provideService(Path.Path, pathService), | ||
| 167 | Effect.provideService(HttpClient.HttpClient, httpClient), | ||
| 157 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), | 168 | Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), |
| 158 | Effect.provideService(OpenCodeRuntime, openCodeRuntime), | 169 | Effect.provideService(OpenCodeRuntime, openCodeRuntime), |
| 159 | ); | 170 | ); |
apps/server/src/provider/Layers/CursorProvider.test.ts+170| ... | @@ -35,6 +35,8 @@ import { | ... | @@ -35,6 +35,8 @@ import { |
| 35 | } from "../Drivers/CursorSkills.ts"; | 35 | } from "../Drivers/CursorSkills.ts"; |
| 36 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | 36 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; |
| 37 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 37 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 38 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 39 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; | ||
| 38 | 40 | ||
| 39 | const runNode = <A, E>( | 41 | const runNode = <A, E>( |
| 40 | effect: Effect.Effect< | 42 | effect: Effect.Effect< |
| ... | @@ -952,3 +954,171 @@ describe("resolveCursorAcpConfigUpdates", () => { | ... | @@ -952,3 +954,171 @@ describe("resolveCursorAcpConfigUpdates", () => { |
| 952 | ]); | 954 | ]); |
| 953 | }); | 955 | }); |
| 954 | }); | 956 | }); |
| 957 | |||
| 958 | describe("Cursor usage limits", () => { | ||
| 959 | const checkedAt = "2026-09-16T00:00:00.000Z"; | ||
| 960 | |||
| 961 | it("uses the advertised percentages and billing-cycle reset", () => { | ||
| 962 | const limits = cursorUsageResponseToLimits( | ||
| 963 | { | ||
| 964 | billingCycleEnd: "1789876386000", | ||
| 965 | planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, | ||
| 966 | }, | ||
| 967 | checkedAt, | ||
| 968 | ); | ||
| 969 | expect(limits.windows).toEqual( | ||
| 970 | expect.arrayContaining([ | ||
| 971 | { | ||
| 972 | id: "totalPercentUsed", | ||
| 973 | kind: "monthly", | ||
| 974 | label: "Monthly", | ||
| 975 | usedPercent: 72.4, | ||
| 976 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 977 | }, | ||
| 978 | { | ||
| 979 | id: "autoPercentUsed", | ||
| 980 | kind: "monthly", | ||
| 981 | label: "Monthly · Auto", | ||
| 982 | usedPercent: 69.5, | ||
| 983 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 984 | }, | ||
| 985 | { | ||
| 986 | id: "apiPercentUsed", | ||
| 987 | kind: "monthly", | ||
| 988 | label: "Monthly · API", | ||
| 989 | usedPercent: 100, | ||
| 990 | resetsAt: "2026-09-20T03:53:06.000Z", | ||
| 991 | }, | ||
| 992 | ]), | ||
| 993 | ); | ||
| 994 | }); | ||
| 995 | |||
| 996 | it("does not invent unused allowance for absent buckets", () => { | ||
| 997 | expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( | ||
| 998 | "unsupported", | ||
| 999 | ); | ||
| 1000 | expect( | ||
| 1001 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, | ||
| 1002 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 0 }]); | ||
| 1003 | expect( | ||
| 1004 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, | ||
| 1005 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Monthly", usedPercent: 100 }]); | ||
| 1006 | }); | ||
| 1007 | |||
| 1008 | it("reads the instance's credentials and endpoint even when usage enabled is false", async () => { | ||
| 1009 | await runNode( | ||
| 1010 | Effect.gen(function* () { | ||
| 1011 | const fs = yield* FileSystem.FileSystem; | ||
| 1012 | const path = yield* Path.Path; | ||
| 1013 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 1014 | yield* fs.makeDirectory(path.join(directory, "cursor")); | ||
| 1015 | yield* fs.writeFileString( | ||
| 1016 | path.join(directory, "cursor", "auth.json"), | ||
| 1017 | '{"accessToken":"instance-token"}', | ||
| 1018 | ); | ||
| 1019 | const client = HttpClient.make((request) => { | ||
| 1020 | expect(request.url).toBe( | ||
| 1021 | "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", | ||
| 1022 | ); | ||
| 1023 | expect(request.method).toBe("POST"); | ||
| 1024 | expect(request.headers.authorization).toBe("Bearer instance-token"); | ||
| 1025 | expect(request.headers["connect-protocol-version"]).toBe("1"); | ||
| 1026 | return Effect.succeed( | ||
| 1027 | HttpClientResponse.fromWeb( | ||
| 1028 | request, | ||
| 1029 | Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), | ||
| 1030 | ), | ||
| 1031 | ); | ||
| 1032 | }); | ||
| 1033 | yield* fs.makeDirectory(path.join(directory, ".cursor")); | ||
| 1034 | yield* fs.writeFileString( | ||
| 1035 | path.join(directory, ".cursor", "auth.json"), | ||
| 1036 | '{"accessToken":"instance-token"}', | ||
| 1037 | ); | ||
| 1038 | for (const platform of ["linux", "darwin"] as const) { | ||
| 1039 | const limits = yield* readCursorUsageLimits( | ||
| 1040 | { apiEndpoint: "https://cursor.example/" }, | ||
| 1041 | { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, | ||
| 1042 | ).pipe( | ||
| 1043 | Effect.provideService(HostProcessPlatform, platform), | ||
| 1044 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 1045 | ); | ||
| 1046 | expect(limits.windows[0]?.usedPercent).toBe(42); | ||
| 1047 | } | ||
| 1048 | }).pipe(Effect.scoped), | ||
| 1049 | ); | ||
| 1050 | }); | ||
| 1051 | |||
| 1052 | it("never reads stale files for keychain or memory logins, but accepts an explicit auth token", async () => { | ||
| 1053 | for (const platform of ["linux", "darwin"] as const) { | ||
| 1054 | for (const token of [undefined, "explicit-token"]) { | ||
| 1055 | const limits = await runNode( | ||
| 1056 | readCursorUsageLimits( | ||
| 1057 | { apiEndpoint: "" }, | ||
| 1058 | { | ||
| 1059 | AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", | ||
| 1060 | ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), | ||
| 1061 | }, | ||
| 1062 | ).pipe( | ||
| 1063 | Effect.provideService(HostProcessPlatform, platform), | ||
| 1064 | Effect.provideService( | ||
| 1065 | FileSystem.FileSystem, | ||
| 1066 | FileSystem.makeNoop({ | ||
| 1067 | readFileString: () => Effect.die("must not read an unrelated credential file"), | ||
| 1068 | }), | ||
| 1069 | ), | ||
| 1070 | Effect.provideService( | ||
| 1071 | HttpClient.HttpClient, | ||
| 1072 | HttpClient.make((request) => { | ||
| 1073 | expect(token).toBe("explicit-token"); | ||
| 1074 | expect(request.headers.authorization).toBe("Bearer explicit-token"); | ||
| 1075 | return Effect.succeed( | ||
| 1076 | HttpClientResponse.fromWeb( | ||
| 1077 | request, | ||
| 1078 | Response.json({ planUsage: { totalPercentUsed: 10 } }), | ||
| 1079 | ), | ||
| 1080 | ); | ||
| 1081 | }), | ||
| 1082 | ), | ||
| 1083 | ), | ||
| 1084 | ); | ||
| 1085 | if (token) expect(limits.windows[0]?.usedPercent).toBe(10); | ||
| 1086 | else expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 1087 | } | ||
| 1088 | } | ||
| 1089 | }); | ||
| 1090 | |||
| 1091 | it("reports failed requests without exposing credentials or response bodies", async () => { | ||
| 1092 | const limits = await runNode( | ||
| 1093 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( | ||
| 1094 | Effect.provideService( | ||
| 1095 | HttpClient.HttpClient, | ||
| 1096 | HttpClient.make((request) => | ||
| 1097 | Effect.succeed( | ||
| 1098 | HttpClientResponse.fromWeb( | ||
| 1099 | request, | ||
| 1100 | new Response("private response", { status: 401 }), | ||
| 1101 | ), | ||
| 1102 | ), | ||
| 1103 | ), | ||
| 1104 | ), | ||
| 1105 | ), | ||
| 1106 | ); | ||
| 1107 | expect(limits.unavailable).toEqual({ | ||
| 1108 | reason: "probeFailed", | ||
| 1109 | message: "Cursor could not read usage limits.", | ||
| 1110 | }); | ||
| 1111 | }); | ||
| 1112 | |||
| 1113 | it("does not use a stored login for an explicit API key", async () => { | ||
| 1114 | const limits = await runNode( | ||
| 1115 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( | ||
| 1116 | Effect.provideService( | ||
| 1117 | HttpClient.HttpClient, | ||
| 1118 | HttpClient.make(() => Effect.die("must not request usage")), | ||
| 1119 | ), | ||
| 1120 | ), | ||
| 1121 | ); | ||
| 1122 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 1123 | }); | ||
| 1124 | }); |
apps/server/src/provider/Layers/GrokProvider.test.ts+251| ... | @@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; | ... | @@ -7,6 +7,7 @@ import { describe, expect, it } from "@effect/vitest"; |
| 7 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 10 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 10 | import { GrokSettings } from "@t3tools/contracts"; | 11 | import { GrokSettings } from "@t3tools/contracts"; |
| 11 | 12 | ||
| 12 | import { | 13 | import { |
| ... | @@ -18,6 +19,7 @@ import { | ... | @@ -18,6 +19,7 @@ import { |
| 18 | parseGrokModelsCliOutput, | 19 | parseGrokModelsCliOutput, |
| 19 | } from "./GrokProvider.ts"; | 20 | } from "./GrokProvider.ts"; |
| 20 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; | 21 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; |
| 22 | import { grokUsageResponseToLimits, readGrokUsageLimits } from "./grokUsageLimits.ts"; | ||
| 21 | 23 | ||
| 22 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); | 24 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 23 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); | 25 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| ... | @@ -530,3 +532,252 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { | ... | @@ -530,3 +532,252 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { |
| 530 | }), | 532 | }), |
| 531 | ); | 533 | ); |
| 532 | }); | 534 | }); |
| 535 | |||
| 536 | describe("Grok usage limits", () => { | ||
| 537 | const checkedAt = "2026-09-16T00:00:00.000Z"; | ||
| 538 | |||
| 539 | it("uses the reported subscription percentage and weekly reset", () => { | ||
| 540 | const limits = grokUsageResponseToLimits( | ||
| 541 | { | ||
| 542 | config: { | ||
| 543 | creditUsagePercent: 100, | ||
| 544 | currentPeriod: { | ||
| 545 | type: "USAGE_PERIOD_TYPE_WEEKLY", | ||
| 546 | end: "2026-09-18T03:10:30.159171+00:00", | ||
| 547 | }, | ||
| 548 | }, | ||
| 549 | }, | ||
| 550 | checkedAt, | ||
| 551 | ); | ||
| 552 | expect(limits.windows).toEqual([ | ||
| 553 | { | ||
| 554 | id: "subscription", | ||
| 555 | kind: "weekly", | ||
| 556 | label: "Weekly", | ||
| 557 | usedPercent: 100, | ||
| 558 | resetsAt: "2026-09-18T03:10:30.159Z", | ||
| 559 | }, | ||
| 560 | ]); | ||
| 561 | }); | ||
| 562 | |||
| 563 | it("does not invent allowance or reset dates when billing omits them", () => { | ||
| 564 | for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { | ||
| 565 | const limits = grokUsageResponseToLimits(response, checkedAt); | ||
| 566 | expect(limits.windows).toEqual([]); | ||
| 567 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 568 | } | ||
| 569 | expect( | ||
| 570 | grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, | ||
| 571 | ).toEqual([{ id: "subscription", kind: "other", label: "Subscription", usedPercent: 0 }]); | ||
| 572 | expect( | ||
| 573 | grokUsageResponseToLimits( | ||
| 574 | { | ||
| 575 | config: { | ||
| 576 | creditUsagePercent: 120, | ||
| 577 | currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "invalid" }, | ||
| 578 | }, | ||
| 579 | }, | ||
| 580 | checkedAt, | ||
| 581 | ).windows, | ||
| 582 | ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); | ||
| 583 | }); | ||
| 584 | }); | ||
| 585 | |||
| 586 | it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { | ||
| 587 | it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => | ||
| 588 | Effect.gen(function* () { | ||
| 589 | const fs = yield* FileSystem.FileSystem; | ||
| 590 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 591 | yield* fs.writeFileString( | ||
| 592 | NodePath.join(directory, "auth.json"), | ||
| 593 | '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', | ||
| 594 | ); | ||
| 595 | const limits = yield* readGrokUsageLimits({ | ||
| 596 | GROK_HOME: directory, | ||
| 597 | HOME: "/unrelated-home", | ||
| 598 | }).pipe( | ||
| 599 | Effect.provideService( | ||
| 600 | HttpClient.HttpClient, | ||
| 601 | HttpClient.make((request) => { | ||
| 602 | expect(request.method).toBe("GET"); | ||
| 603 | expect(request.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits"); | ||
| 604 | expect(request.headers.authorization).toBe("Bearer session-token"); | ||
| 605 | return Effect.succeed( | ||
| 606 | HttpClientResponse.fromWeb( | ||
| 607 | request, | ||
| 608 | Response.json({ config: { creditUsagePercent: 37 } }), | ||
| 609 | ), | ||
| 610 | ); | ||
| 611 | }), | ||
| 612 | ), | ||
| 613 | ); | ||
| 614 | expect(limits.windows[0]?.usedPercent).toBe(37); | ||
| 615 | }).pipe(Effect.scoped), | ||
| 616 | ); | ||
| 617 | |||
| 618 | it.effect( | ||
| 619 | "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", | ||
| 620 | () => | ||
| 621 | readGrokUsageLimits({ | ||
| 622 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', | ||
| 623 | }).pipe( | ||
| 624 | Effect.provideService( | ||
| 625 | FileSystem.FileSystem, | ||
| 626 | FileSystem.makeNoop({ | ||
| 627 | readFileString: (path) => | ||
| 628 | path.endsWith("auth.json") | ||
| 629 | ? Effect.die("must not read stored credentials") | ||
| 630 | : Effect.succeed(""), | ||
| 631 | }), | ||
| 632 | ), | ||
| 633 | Effect.provideService( | ||
| 634 | HttpClient.HttpClient, | ||
| 635 | HttpClient.make((request) => { | ||
| 636 | expect(request.headers.authorization).toBe("Bearer legacy-token"); | ||
| 637 | return Effect.succeed( | ||
| 638 | HttpClientResponse.fromWeb( | ||
| 639 | request, | ||
| 640 | Response.json({ config: { creditUsagePercent: 12 } }), | ||
| 641 | ), | ||
| 642 | ); | ||
| 643 | }), | ||
| 644 | ), | ||
| 645 | Effect.tap((limits) => Effect.sync(() => expect(limits.windows[0]?.usedPercent).toBe(12))), | ||
| 646 | ), | ||
| 647 | ); | ||
| 648 | |||
| 649 | it.effect("does not use unrelated scopes, API keys, or custom auth deployments", () => | ||
| 650 | Effect.gen(function* () { | ||
| 651 | for (const environment of [ | ||
| 652 | { | ||
| 653 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 654 | XAI_API_KEY: "different-api-account", | ||
| 655 | }, | ||
| 656 | { GROK_AUTH: '{"https://other.example":{"key":"unrelated-token"}}' }, | ||
| 657 | { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"api-key","auth_mode":"api_key"}}' }, | ||
| 658 | { GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":" "}}' }, | ||
| 659 | { | ||
| 660 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 661 | GROK_OIDC_ISSUER: "https://custom.example", | ||
| 662 | }, | ||
| 663 | { | ||
| 664 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 665 | GROK_MODELS_BASE_URL: "https://custom.example/v1", | ||
| 666 | }, | ||
| 667 | { | ||
| 668 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 669 | GROK_OAUTH2_PRINCIPAL_TYPE: "Team", | ||
| 670 | }, | ||
| 671 | { | ||
| 672 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-account"}}', | ||
| 673 | GROK_OAUTH2_PRINCIPAL_ID: "team-id", | ||
| 674 | }, | ||
| 675 | { | ||
| 676 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 677 | GROK_CONFIG: '{"auth_provider_command":"custom-auth"}', | ||
| 678 | }, | ||
| 679 | { | ||
| 680 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"session-token"}}', | ||
| 681 | GROK_CONFIG_PATH: "/custom-config.toml", | ||
| 682 | }, | ||
| 683 | ]) { | ||
| 684 | const limits = yield* readGrokUsageLimits({ | ||
| 685 | HOME: "/definitely/not/a/grok-home", | ||
| 686 | ...environment, | ||
| 687 | }).pipe( | ||
| 688 | Effect.provideService( | ||
| 689 | HttpClient.HttpClient, | ||
| 690 | HttpClient.make(() => Effect.die("must not request another account's quota")), | ||
| 691 | ), | ||
| 692 | ); | ||
| 693 | expect(limits.windows).toEqual([]); | ||
| 694 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 695 | } | ||
| 696 | }), | ||
| 697 | ); | ||
| 698 | |||
| 699 | it.effect( | ||
| 700 | "reports a missing login as unsupported and malformed credentials as a sanitized failure", | ||
| 701 | () => | ||
| 702 | Effect.gen(function* () { | ||
| 703 | const fs = yield* FileSystem.FileSystem; | ||
| 704 | const directory = yield* fs.makeTempDirectoryScoped(); | ||
| 705 | const client = HttpClient.make(() => Effect.die("must not request without credentials")); | ||
| 706 | const missing = yield* readGrokUsageLimits({ HOME: directory }).pipe( | ||
| 707 | Effect.provideService(HttpClient.HttpClient, client), | ||
| 708 | ); | ||
| 709 | expect(missing.unavailable?.reason).toBe("unsupported"); | ||
| 710 | for (const contents of [ | ||
| 711 | "private-token-invalid-json", | ||
| 712 | '{"https://accounts.x.ai/sign-in":{"key":42}}', | ||
| 713 | ]) { | ||
| 714 | const malformed = yield* readGrokUsageLimits({ | ||
| 715 | GROK_HOME: directory, | ||
| 716 | GROK_AUTH: contents, | ||
| 717 | }).pipe(Effect.provideService(HttpClient.HttpClient, client)); | ||
| 718 | expect(malformed.unavailable).toEqual({ | ||
| 719 | reason: "probeFailed", | ||
| 720 | message: "Grok could not read usage limits.", | ||
| 721 | }); | ||
| 722 | } | ||
| 723 | }).pipe(Effect.scoped), | ||
| 724 | ); | ||
| 725 | |||
| 726 | it.effect( | ||
| 727 | "does not request subscription limits for custom account or endpoint configuration", | ||
| 728 | () => | ||
| 729 | Effect.gen(function* () { | ||
| 730 | for (const config of [ | ||
| 731 | '[auth]\nprovider_command = "custom-auth"', | ||
| 732 | '[grok_com_config]\nissuer = "https://custom.example"', | ||
| 733 | 'endpoints.proxy = "https://custom.example"', | ||
| 734 | ]) { | ||
| 735 | const limits = yield* readGrokUsageLimits({ | ||
| 736 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', | ||
| 737 | }).pipe( | ||
| 738 | Effect.provideService( | ||
| 739 | FileSystem.FileSystem, | ||
| 740 | FileSystem.makeNoop({ | ||
| 741 | readFileString: (path) => { | ||
| 742 | expect(path.endsWith("config.toml")).toBe(true); | ||
| 743 | return Effect.succeed(config); | ||
| 744 | }, | ||
| 745 | }), | ||
| 746 | ), | ||
| 747 | Effect.provideService( | ||
| 748 | HttpClient.HttpClient, | ||
| 749 | HttpClient.make(() => Effect.die("must not request another account's quota")), | ||
| 750 | ), | ||
| 751 | ); | ||
| 752 | expect(limits.windows).toEqual([]); | ||
| 753 | expect(limits.unavailable?.reason).toBe("unsupported"); | ||
| 754 | } | ||
| 755 | }), | ||
| 756 | ); | ||
| 757 | |||
| 758 | it.effect("sanitizes HTTP failures and malformed billing responses", () => | ||
| 759 | Effect.gen(function* () { | ||
| 760 | for (const response of [ | ||
| 761 | new Response("private response", { status: 401 }), | ||
| 762 | Response.json({ config: { creditUsagePercent: "private-value" } }), | ||
| 763 | ]) { | ||
| 764 | const limits = yield* readGrokUsageLimits({ | ||
| 765 | HOME: "/definitely/not/a/grok-home", | ||
| 766 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"private-token"}}', | ||
| 767 | }).pipe( | ||
| 768 | Effect.provideService( | ||
| 769 | HttpClient.HttpClient, | ||
| 770 | HttpClient.make((request) => | ||
| 771 | Effect.succeed(HttpClientResponse.fromWeb(request, response)), | ||
| 772 | ), | ||
| 773 | ), | ||
| 774 | ); | ||
| 775 | expect(limits.windows).toEqual([]); | ||
| 776 | expect(limits.unavailable).toEqual({ | ||
| 777 | reason: "probeFailed", | ||
| 778 | message: "Grok could not read usage limits.", | ||
| 779 | }); | ||
| 780 | } | ||
| 781 | }), | ||
| 782 | ); | ||
| 783 | }); |
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+117| ... | @@ -4,9 +4,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -4,9 +4,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; | 4 | import { it } from "@effect/vitest"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Fiber from "effect/Fiber"; | 6 | import * as Fiber from "effect/Fiber"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 8 | import * as Schema from "effect/Schema"; | 9 | import * as Schema from "effect/Schema"; |
| 9 | import * as TestClock from "effect/testing/TestClock"; | 10 | import * as TestClock from "effect/testing/TestClock"; |
| 11 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 10 | import { beforeEach } from "vite-plus/test"; | 12 | import { beforeEach } from "vite-plus/test"; |
| 11 | 13 | ||
| 12 | import { OpenCodeSettings } from "@t3tools/contracts"; | 14 | import { OpenCodeSettings } from "@t3tools/contracts"; |
| ... | @@ -23,10 +25,125 @@ import { | ... | @@ -23,10 +25,125 @@ import { |
| 23 | openCodeCommandsToServerProviderSlashCommands, | 25 | openCodeCommandsToServerProviderSlashCommands, |
| 24 | } from "./OpenCodeProvider.ts"; | 26 | } from "./OpenCodeProvider.ts"; |
| 25 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; | 27 | import type { OpenCodeInventory } from "../opencodeRuntime.ts"; |
| 28 | import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts"; | ||
| 26 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); | 29 | const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); |
| 27 | 30 | ||
| 28 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; | 31 | const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n"; |
| 29 | 32 | ||
| 33 | it.effect("reads Go limits with the instance's XDG credentials and preserves reset times", () => | ||
| 34 | Effect.gen(function* () { | ||
| 35 | const resetsAt = "2026-09-17T12:00:00.000Z"; | ||
| 36 | const limits = yield* readOpenCodeGoUsageLimits({ | ||
| 37 | enabled: true, | ||
| 38 | serverUrl: "", | ||
| 39 | environment: { XDG_DATA_HOME: "/instance/data", OPENCODE_API_KEY: "env-key" }, | ||
| 40 | }).pipe( | ||
| 41 | Effect.provideService( | ||
| 42 | FileSystem.FileSystem, | ||
| 43 | FileSystem.makeNoop({ | ||
| 44 | readFileString: (path) => { | ||
| 45 | NodeAssert.equal(path, "/instance/data/opencode/auth.json"); | ||
| 46 | return Effect.succeed( | ||
| 47 | JSON.stringify({ "opencode-go": { type: "api", key: "instance-key" } }), | ||
| 48 | ); | ||
| 49 | }, | ||
| 50 | }), | ||
| 51 | ), | ||
| 52 | Effect.provideService( | ||
| 53 | HttpClient.HttpClient, | ||
| 54 | HttpClient.make((request) => { | ||
| 55 | NodeAssert.equal(request.url, "https://opencode.ai/zen/go/v1/usage"); | ||
| 56 | NodeAssert.equal(request.headers.authorization, "Bearer instance-key"); | ||
| 57 | return Effect.succeed( | ||
| 58 | HttpClientResponse.fromWeb( | ||
| 59 | request, | ||
| 60 | Response.json({ | ||
| 61 | usage: { | ||
| 62 | rolling: { percent: 0, resetsAt }, | ||
| 63 | weekly: { percent: 10, resetsAt }, | ||
| 64 | monthly: { percent: 125, resetsAt }, | ||
| 65 | }, | ||
| 66 | }), | ||
| 67 | ), | ||
| 68 | ); | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | Effect.provide(NodeServices.layer), | ||
| 72 | ); | ||
| 73 | NodeAssert.equal(limits.unavailable, undefined); | ||
| 74 | NodeAssert.deepEqual( | ||
| 75 | limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({ | ||
| 76 | kind, | ||
| 77 | usedPercent, | ||
| 78 | reset, | ||
| 79 | })), | ||
| 80 | [ | ||
| 81 | { kind: "session", usedPercent: 0, reset: resetsAt }, | ||
| 82 | { kind: "weekly", usedPercent: 10, reset: resetsAt }, | ||
| 83 | { kind: "monthly", usedPercent: 100, reset: resetsAt }, | ||
| 84 | ], | ||
| 85 | ); | ||
| 86 | }), | ||
| 87 | ); | ||
| 88 | |||
| 89 | it.effect("does not read local credentials for external or disabled OpenCode instances", () => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | for (const settings of [ | ||
| 92 | { enabled: true, serverUrl: "https://remote.example" }, | ||
| 93 | { enabled: false, serverUrl: "" }, | ||
| 94 | ]) { | ||
| 95 | const limits = yield* readOpenCodeGoUsageLimits({ ...settings, environment: {} }).pipe( | ||
| 96 | Effect.provideService( | ||
| 97 | FileSystem.FileSystem, | ||
| 98 | FileSystem.makeNoop({ | ||
| 99 | readFileString: () => Effect.die("unexpected credential read"), | ||
| 100 | }), | ||
| 101 | ), | ||
| 102 | Effect.provideService( | ||
| 103 | HttpClient.HttpClient, | ||
| 104 | HttpClient.make(() => Effect.die("unexpected usage request")), | ||
| 105 | ), | ||
| 106 | Effect.provide(NodeServices.layer), | ||
| 107 | ); | ||
| 108 | NodeAssert.equal(limits.unavailable?.reason, "unsupported"); | ||
| 109 | } | ||
| 110 | }), | ||
| 111 | ); | ||
| 112 | |||
| 113 | it.effect("keeps Go entitlement absence distinct from failed or malformed usage responses", () => | ||
| 114 | Effect.gen(function* () { | ||
| 115 | for (const [status, reason] of [ | ||
| 116 | [403, "unsupported"], | ||
| 117 | [401, "probeFailed"], | ||
| 118 | [200, "probeFailed"], | ||
| 119 | ] as const) { | ||
| 120 | const limits = yield* readOpenCodeGoUsageLimits({ | ||
| 121 | enabled: true, | ||
| 122 | serverUrl: "", | ||
| 123 | environment: { | ||
| 124 | OPENCODE_AUTH_CONTENT: '{"opencode-go":{"type":"api","key":"inline-key"}}', | ||
| 125 | }, | ||
| 126 | }).pipe( | ||
| 127 | Effect.provideService( | ||
| 128 | FileSystem.FileSystem, | ||
| 129 | FileSystem.makeNoop({ | ||
| 130 | readFileString: () => Effect.die("inline credentials must bypass disk"), | ||
| 131 | }), | ||
| 132 | ), | ||
| 133 | Effect.provideService( | ||
| 134 | HttpClient.HttpClient, | ||
| 135 | HttpClient.make((request) => | ||
| 136 | Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({}, { status }))), | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | Effect.provide(NodeServices.layer), | ||
| 140 | ); | ||
| 141 | NodeAssert.equal(limits.unavailable?.reason, reason); | ||
| 142 | NodeAssert.deepEqual(limits.windows, []); | ||
| 143 | } | ||
| 144 | }), | ||
| 145 | ); | ||
| 146 | |||
| 30 | /** | 147 | /** |
| 31 | * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag | 148 | * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag |
| 32 | * are deleted. The snapshot-producing logic they wrapped now lives in the | 149 | * are deleted. The snapshot-producing logic they wrapped now lives in the |
apps/server/src/provider/Layers/cursorUsageLimits.ts created+140| ... | @@ -0,0 +1,140 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 4 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 11 | import { | ||
| 12 | clampPercent, | ||
| 13 | makeUnavailableUsageLimits, | ||
| 14 | makeUsageLimits, | ||
| 15 | } from "../providerUsageLimits.ts"; | ||
| 16 | |||
| 17 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); | ||
| 18 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); | ||
| 19 | const CursorUsageResponse = Schema.Struct({ | ||
| 20 | billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), | ||
| 21 | planUsage: Schema.optional( | ||
| 22 | Schema.Struct({ | ||
| 23 | totalPercentUsed: Schema.optional(Schema.Number), | ||
| 24 | autoPercentUsed: Schema.optional(Schema.Number), | ||
| 25 | apiPercentUsed: Schema.optional(Schema.Number), | ||
| 26 | }), | ||
| 27 | ), | ||
| 28 | }); | ||
| 29 | |||
| 30 | /** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ | ||
| 31 | export function cursorUsageResponseToLimits( | ||
| 32 | response: typeof CursorUsageResponse.Type, | ||
| 33 | checkedAt: string, | ||
| 34 | ) { | ||
| 35 | const reset = DateTime.make(Number(response.billingCycleEnd)); | ||
| 36 | const resetsAt = | ||
| 37 | Number(response.billingCycleEnd) > 0 && Option.isSome(reset) | ||
| 38 | ? DateTime.formatIso(reset.value) | ||
| 39 | : undefined; | ||
| 40 | const windows: ServerProviderUsageWindow[] = []; | ||
| 41 | if (response.planUsage) { | ||
| 42 | for (const [key, label] of [ | ||
| 43 | ["totalPercentUsed", "Monthly"], | ||
| 44 | ["autoPercentUsed", "Monthly · Auto"], | ||
| 45 | ["apiPercentUsed", "Monthly · API"], | ||
| 46 | ] as const) { | ||
| 47 | const usedPercent = response.planUsage[key]; | ||
| 48 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; | ||
| 49 | windows.push({ | ||
| 50 | id: key, | ||
| 51 | kind: "monthly", | ||
| 52 | label, | ||
| 53 | usedPercent: clampPercent(usedPercent), | ||
| 54 | ...(resetsAt ? { resetsAt } : {}), | ||
| 55 | }); | ||
| 56 | } | ||
| 57 | } | ||
| 58 | return windows.length > 0 | ||
| 59 | ? makeUsageLimits({ checkedAt, windows }) | ||
| 60 | : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 61 | } | ||
| 62 | |||
| 63 | export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( | ||
| 64 | settings: Pick<CursorSettings, "apiEndpoint">, | ||
| 65 | environment: NodeJS.ProcessEnv = process.env, | ||
| 66 | ) { | ||
| 67 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 68 | return yield* Effect.gen(function* () { | ||
| 69 | const fs = yield* FileSystem.FileSystem; | ||
| 70 | const path = yield* Path.Path; | ||
| 71 | const platform = yield* HostProcessPlatform; | ||
| 72 | let token = environment.CURSOR_AUTH_TOKEN?.trim(); | ||
| 73 | // An explicit API key can name a different account from the stored login. | ||
| 74 | if (!token && environment.CURSOR_API_KEY?.trim()) { | ||
| 75 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 76 | } | ||
| 77 | const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; | ||
| 78 | if ( | ||
| 79 | !token && | ||
| 80 | (credentialStore === "memory" || (platform === "darwin" && credentialStore !== "file")) | ||
| 81 | ) { | ||
| 82 | // Cursor's default macOS login lives in the keychain; a leftover file may be another account. | ||
| 83 | return makeUnavailableUsageLimits({ | ||
| 84 | checkedAt, | ||
| 85 | reason: "unsupported", | ||
| 86 | message: "Cursor usage requires a file-based login or CURSOR_AUTH_TOKEN.", | ||
| 87 | }); | ||
| 88 | } | ||
| 89 | if (!token) { | ||
| 90 | const home = | ||
| 91 | (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); | ||
| 92 | const directory = | ||
| 93 | platform === "win32" | ||
| 94 | ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") | ||
| 95 | : platform === "darwin" | ||
| 96 | ? path.join(home, ".cursor") | ||
| 97 | : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); | ||
| 98 | const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( | ||
| 99 | Effect.catchTags({ | ||
| 100 | PlatformError: (error) => | ||
| 101 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 102 | }), | ||
| 103 | Effect.flatMap(decodeCredentials), | ||
| 104 | ); | ||
| 105 | token = credentials.accessToken?.trim(); | ||
| 106 | } | ||
| 107 | if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 108 | const client = yield* HttpClient.HttpClient; | ||
| 109 | const endpoint = ( | ||
| 110 | settings.apiEndpoint.trim() || | ||
| 111 | environment.CURSOR_API_ENDPOINT?.trim() || | ||
| 112 | "https://api2.cursor.sh" | ||
| 113 | ).replace(/\/$/, ""); | ||
| 114 | const response = yield* client.execute( | ||
| 115 | HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( | ||
| 116 | HttpClientRequest.bearerToken(token), | ||
| 117 | HttpClientRequest.setHeaders({ | ||
| 118 | "connect-protocol-version": "1", | ||
| 119 | "x-cursor-client-type": "cli", | ||
| 120 | }), | ||
| 121 | HttpClientRequest.bodyJsonUnsafe({}), | ||
| 122 | ), | ||
| 123 | ); | ||
| 124 | const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( | ||
| 125 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 126 | ); | ||
| 127 | return cursorUsageResponseToLimits(body, checkedAt); | ||
| 128 | }).pipe( | ||
| 129 | Effect.timeout("10 seconds"), | ||
| 130 | Effect.catch(() => | ||
| 131 | Effect.succeed( | ||
| 132 | makeUnavailableUsageLimits({ | ||
| 133 | checkedAt, | ||
| 134 | reason: "probeFailed", | ||
| 135 | message: "Cursor could not read usage limits.", | ||
| 136 | }), | ||
| 137 | ), | ||
| 138 | ), | ||
| 139 | ); | ||
| 140 | }); | ||
apps/server/src/provider/Layers/grokUsageLimits.ts created+148| ... | @@ -0,0 +1,148 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 3 | import * as DateTime from "effect/DateTime"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 6 | import * as Option from "effect/Option"; | ||
| 7 | import * as Path from "effect/Path"; | ||
| 8 | import * as Schema from "effect/Schema"; | ||
| 9 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 10 | import { | ||
| 11 | clampPercent, | ||
| 12 | makeUnavailableUsageLimits, | ||
| 13 | makeUsageLimits, | ||
| 14 | } from "../providerUsageLimits.ts"; | ||
| 15 | |||
| 16 | const GrokCredentials = Schema.Record( | ||
| 17 | Schema.String, | ||
| 18 | Schema.Struct({ | ||
| 19 | key: Schema.optional(Schema.String), | ||
| 20 | auth_mode: Schema.optional(Schema.String), | ||
| 21 | }), | ||
| 22 | ); | ||
| 23 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); | ||
| 24 | const GrokUsageResponse = Schema.Struct({ | ||
| 25 | config: Schema.optional( | ||
| 26 | Schema.Struct({ | ||
| 27 | creditUsagePercent: Schema.optional(Schema.Number), | ||
| 28 | currentPeriod: Schema.optional( | ||
| 29 | Schema.Struct({ | ||
| 30 | type: Schema.optional(Schema.String), | ||
| 31 | end: Schema.optional(Schema.String), | ||
| 32 | }), | ||
| 33 | ), | ||
| 34 | }), | ||
| 35 | ), | ||
| 36 | }); | ||
| 37 | |||
| 38 | export function grokUsageResponseToLimits( | ||
| 39 | response: typeof GrokUsageResponse.Type, | ||
| 40 | checkedAt: string, | ||
| 41 | ) { | ||
| 42 | const usedPercent = response.config?.creditUsagePercent; | ||
| 43 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) { | ||
| 44 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 45 | } | ||
| 46 | const period = response.config?.currentPeriod; | ||
| 47 | const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); | ||
| 48 | const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; | ||
| 49 | const reset = period?.end ? DateTime.make(period.end) : Option.none(); | ||
| 50 | const window: ServerProviderUsageWindow = { | ||
| 51 | id: "subscription", | ||
| 52 | kind, | ||
| 53 | label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", | ||
| 54 | usedPercent: clampPercent(usedPercent), | ||
| 55 | ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), | ||
| 56 | }; | ||
| 57 | return makeUsageLimits({ checkedAt, windows: [window] }); | ||
| 58 | } | ||
| 59 | |||
| 60 | export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( | ||
| 61 | environment: NodeJS.ProcessEnv = process.env, | ||
| 62 | ) { | ||
| 63 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 64 | return yield* Effect.gen(function* () { | ||
| 65 | // T3's ACP adapter explicitly selects API-key auth when this variable is set. | ||
| 66 | if (environment.XAI_API_KEY?.trim()) { | ||
| 67 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 68 | } | ||
| 69 | // Alternate auth deployments can select another scope or account from the same file. | ||
| 70 | if ( | ||
| 71 | [ | ||
| 72 | "GROK_OIDC_ISSUER", | ||
| 73 | "GROK_OIDC_CLIENT_ID", | ||
| 74 | "GROK_OAUTH2_ISSUER", | ||
| 75 | "GROK_OAUTH2_CLIENT_ID", | ||
| 76 | "GROK_OAUTH2_PRINCIPAL_TYPE", | ||
| 77 | "GROK_OAUTH2_PRINCIPAL_ID", | ||
| 78 | "GROK_AUTH_PROVIDER_COMMAND", | ||
| 79 | "GROK_LOCAL_AUTH", | ||
| 80 | "GROK_CLI_CHAT_PROXY_BASE_URL", | ||
| 81 | "GROK_MODELS_BASE_URL", | ||
| 82 | "GROK_CONFIG", | ||
| 83 | "GROK_CONFIG_PATH", | ||
| 84 | ].some((name) => environment[name]?.trim()) | ||
| 85 | ) { | ||
| 86 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 87 | } | ||
| 88 | const fs = yield* FileSystem.FileSystem; | ||
| 89 | const path = yield* Path.Path; | ||
| 90 | const home = | ||
| 91 | environment.GROK_HOME?.trim() || | ||
| 92 | path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); | ||
| 93 | for (const configPath of [ | ||
| 94 | path.join(home, "config.toml"), | ||
| 95 | path.join(home, "managed_config.toml"), | ||
| 96 | path.join(home, "requirements.toml"), | ||
| 97 | "/etc/grok/managed_config.toml", | ||
| 98 | "/etc/grok/requirements.toml", | ||
| 99 | ]) { | ||
| 100 | const config = yield* fs.readFileString(configPath).pipe( | ||
| 101 | Effect.catchTags({ | ||
| 102 | PlatformError: (error) => | ||
| 103 | error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), | ||
| 104 | }), | ||
| 105 | ); | ||
| 106 | // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. | ||
| 107 | if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { | ||
| 108 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 109 | } | ||
| 110 | } | ||
| 111 | const contents = | ||
| 112 | environment.GROK_AUTH?.trim() || | ||
| 113 | (yield* fs.readFileString(path.join(home, "auth.json")).pipe( | ||
| 114 | Effect.catchTags({ | ||
| 115 | PlatformError: (error) => | ||
| 116 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 117 | }), | ||
| 118 | )); | ||
| 119 | const credentials = yield* decodeCredentials(contents); | ||
| 120 | // Never pick an arbitrary account from other deployments stored in the same file. | ||
| 121 | const credential = | ||
| 122 | credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? | ||
| 123 | credentials["https://accounts.x.ai/sign-in"]; | ||
| 124 | const token = credential?.auth_mode === "api_key" ? undefined : credential?.key?.trim(); | ||
| 125 | if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 126 | const client = yield* HttpClient.HttpClient; | ||
| 127 | const response = yield* client.execute( | ||
| 128 | HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( | ||
| 129 | HttpClientRequest.bearerToken(token), | ||
| 130 | ), | ||
| 131 | ); | ||
| 132 | const body = yield* HttpClientResponse.schemaBodyJson(GrokUsageResponse)( | ||
| 133 | yield* HttpClientResponse.filterStatusOk(response), | ||
| 134 | ); | ||
| 135 | return grokUsageResponseToLimits(body, checkedAt); | ||
| 136 | }).pipe( | ||
| 137 | Effect.timeout("10 seconds"), | ||
| 138 | Effect.catch(() => | ||
| 139 | Effect.succeed( | ||
| 140 | makeUnavailableUsageLimits({ | ||
| 141 | checkedAt, | ||
| 142 | reason: "probeFailed", | ||
| 143 | message: "Grok could not read usage limits.", | ||
| 144 | }), | ||
| 145 | ), | ||
| 146 | ), | ||
| 147 | ); | ||
| 148 | }); | ||
apps/server/src/provider/Layers/openCodeUsageLimits.ts created+109| ... | @@ -0,0 +1,109 @@ | ||
| 1 | import * as NodeOS from "node:os"; | ||
| 2 | |||
| 3 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | ||
| 4 | import * as DateTime from "effect/DateTime"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Option from "effect/Option"; | ||
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 10 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 11 | |||
| 12 | import { | ||
| 13 | clampPercent, | ||
| 14 | makeUnavailableUsageLimits, | ||
| 15 | makeUsageLimits, | ||
| 16 | } from "../providerUsageLimits.ts"; | ||
| 17 | |||
| 18 | const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) }); | ||
| 19 | const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String }); | ||
| 20 | const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile)); | ||
| 21 | const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth); | ||
| 22 | const UsageWindow = Schema.Struct({ | ||
| 23 | percent: Schema.Finite, | ||
| 24 | resetsAt: Schema.DateTimeUtcFromString, | ||
| 25 | }); | ||
| 26 | const UsageResponse = Schema.Struct({ | ||
| 27 | usage: Schema.Struct({ rolling: UsageWindow, weekly: UsageWindow, monthly: UsageWindow }), | ||
| 28 | }); | ||
| 29 | |||
| 30 | /** External OpenCode servers own their credentials; never read the host's account for them. */ | ||
| 31 | export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(function* (input: { | ||
| 32 | readonly enabled: boolean; | ||
| 33 | readonly serverUrl: string; | ||
| 34 | readonly environment: NodeJS.ProcessEnv; | ||
| 35 | }) { | ||
| 36 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | ||
| 37 | const unsupported = makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | ||
| 38 | if (!input.enabled || input.serverUrl.trim()) return unsupported; | ||
| 39 | |||
| 40 | return yield* Effect.gen(function* () { | ||
| 41 | const fs = yield* FileSystem.FileSystem; | ||
| 42 | const path = yield* Path.Path; | ||
| 43 | const env = input.environment; | ||
| 44 | const dataHome = | ||
| 45 | env.XDG_DATA_HOME || | ||
| 46 | path.join(env.HOME || env.USERPROFILE || NodeOS.homedir(), ".local", "share"); | ||
| 47 | const authPath = path.join(dataHome, "opencode", "auth.json"); | ||
| 48 | const contents = | ||
| 49 | env.OPENCODE_AUTH_CONTENT || | ||
| 50 | (yield* fs.readFileString(authPath).pipe( | ||
| 51 | Effect.catchTags({ | ||
| 52 | PlatformError: (error) => | ||
| 53 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | ||
| 54 | }), | ||
| 55 | )); | ||
| 56 | const auth = yield* decodeAuthFile(contents); | ||
| 57 | const apiAuth = decodeApiAuth(auth["opencode-go"]); | ||
| 58 | // OpenCode overlays stored API credentials after environment credentials. | ||
| 59 | const apiKey = (Option.isSome(apiAuth) ? apiAuth.value.key : env.OPENCODE_API_KEY)?.trim(); | ||
| 60 | if (!apiKey) return unsupported; | ||
| 61 | |||
| 62 | const client = yield* HttpClient.HttpClient; | ||
| 63 | const response = yield* client.execute( | ||
| 64 | HttpClientRequest.get("https://opencode.ai/zen/go/v1/usage").pipe( | ||
| 65 | HttpClientRequest.bearerToken(apiKey), | ||
| 66 | ), | ||
| 67 | ); | ||
| 68 | // A valid Zen key can exist without a Go subscription. | ||
| 69 | if (response.status === 403) return unsupported; | ||
| 70 | const body = yield* HttpClientResponse.filterStatusOk(response).pipe( | ||
| 71 | Effect.flatMap(HttpClientResponse.schemaBodyJson(UsageResponse)), | ||
| 72 | ); | ||
| 73 | const windows: ServerProviderUsageWindow[] = [ | ||
| 74 | { | ||
| 75 | id: "go_rolling", | ||
| 76 | kind: "session", | ||
| 77 | label: "Go · Session", | ||
| 78 | windowDurationMins: 5 * 60, | ||
| 79 | usedPercent: clampPercent(body.usage.rolling.percent), | ||
| 80 | resetsAt: DateTime.formatIso(body.usage.rolling.resetsAt), | ||
| 81 | }, | ||
| 82 | { | ||
| 83 | id: "go_weekly", | ||
| 84 | kind: "weekly", | ||
| 85 | label: "Go · Weekly", | ||
| 86 | windowDurationMins: 7 * 24 * 60, | ||
| 87 | usedPercent: clampPercent(body.usage.weekly.percent), | ||
| 88 | resetsAt: DateTime.formatIso(body.usage.weekly.resetsAt), | ||
| 89 | }, | ||
| 90 | { | ||
| 91 | id: "go_monthly", | ||
| 92 | kind: "monthly", | ||
| 93 | label: "Go · Monthly", | ||
| 94 | usedPercent: clampPercent(body.usage.monthly.percent), | ||
| 95 | resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt), | ||
| 96 | }, | ||
| 97 | ]; | ||
| 98 | return makeUsageLimits({ checkedAt, windows }); | ||
| 99 | }).pipe( | ||
| 100 | Effect.timeout("5 seconds"), | ||
| 101 | Effect.orElseSucceed(() => | ||
| 102 | makeUnavailableUsageLimits({ | ||
| 103 | checkedAt, | ||
| 104 | reason: "probeFailed", | ||
| 105 | message: "OpenCode Go could not read usage.", | ||
| 106 | }), | ||
| 107 | ), | ||
| 108 | ); | ||
| 109 | }); | ||
docs/user/usage.md+12| ... | @@ -69,6 +69,18 @@ current model's limits without leaving the conversation. The result opens above | ... | @@ -69,6 +69,18 @@ current model's limits without leaving the conversation. The result opens above |
| 69 | 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 | 69 | 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 |
| 70 | anything. The command is offered only for providers that appear under **Usage → Limits**. | 70 | anything. The command is offered only for providers that appear under **Usage → Limits**. |
| 71 | 71 | ||
| 72 | OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in | ||
| 73 | the environment. T3 cannot report limits for external OpenCode servers because their credentials | ||
| 74 | belong to the remote server. Cursor reports | ||
| 75 | its monthly allowance, including separate Auto and API usage, using a file-based CLI login or | ||
| 76 | `CURSOR_AUTH_TOKEN`. Cursor's default macOS keychain login does not currently report limits. | ||
| 77 | On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment | ||
| 78 | to use a file-based login. | ||
| 79 | |||
| 80 | Grok reports the remaining subscription allowance and reset time for its current billing period | ||
| 81 | after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication | ||
| 82 | or endpoint configurations do not report subscription limits. | ||
| 83 | |||
| 72 | API-key accounts may not report subscription limits. This also applies to Claude connections | 84 | API-key accounts may not report subscription limits. This also applies to Claude connections |
| 73 | using a proxy through `ANTHROPIC_AUTH_TOKEN`. | 85 | using a proxy through `ANTHROPIC_AUTH_TOKEN`. |
| 74 | 86 |