authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-16 16:42:00-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 16:42:00-03:00
logeff44be433dc9683da7764a20334598e47b127b2
tree9a82b6180a6ce96759e5501fa03c0b371d7ada50
parent052c7ae53e46c705d5fd5dfa995275bc3100744e
signature Signed by PGP key B5690EEEBB952194

feat(usage): show OpenCode Go, Cursor, and Grok subscription limits (#12115)

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";
2626import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts";
2727import { ProviderDriverError } from "../Errors.ts";
2828import { makeCursorAdapter } from "../Layers/CursorAdapter.ts";
29import { readCursorUsageLimits } from "../Layers/cursorUsageLimits.ts";
2930import {
3031 buildInitialCursorProviderSnapshot,
3132 checkCursorProviderStatus,
......@@ -139,7 +140,15 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = {
139140 processEnv,
140141 discoverModels,
141142 ).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 ),
142150 Effect.map(stampIdentity),
151 Effect.provideService(HttpClient.HttpClient, httpClient),
143152 Effect.provideService(Crypto.Crypto, crypto),
144153 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
145154 Effect.provideService(FileSystem.FileSystem, fileSystem),
apps/server/src/provider/Drivers/GrokDriver.ts+13
......@@ -19,6 +19,7 @@ import {
1919 enrichGrokSnapshot,
2020} from "../Layers/GrokProvider.ts";
2121import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
22import { readGrokUsageLimits } from "../Layers/grokUsageLimits.ts";
2223import { makeManagedServerProvider } from "../makeManagedServerProvider.ts";
2324import {
2425 defaultProviderContinuationIdentity,
......@@ -66,6 +67,8 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
6667 const crypto = yield* Crypto.Crypto;
6768 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
6869 const httpClient = yield* HttpClient.HttpClient;
70 const fileSystem = yield* FileSystem.FileSystem;
71 const path = yield* Path.Path;
6972 const serverSettings = yield* ServerSettingsService;
7073 const { cwd } = yield* ServerConfig;
7174 const eventLoggers = yield* ProviderEventLoggers;
......@@ -90,7 +93,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
9093 const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv);
9194
9295 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 ),
93103 Effect.map(stampIdentity),
104 Effect.provideService(HttpClient.HttpClient, httpClient),
105 Effect.provideService(FileSystem.FileSystem, fileSystem),
106 Effect.provideService(Path.Path, path),
94107 Effect.provideService(Crypto.Crypto, crypto),
95108 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
96109 );
apps/server/src/provider/Drivers/OpenCodeDriver.ts+15-4
......@@ -27,6 +27,7 @@ import { ServerConfig } from "../../config.ts";
2727import { ServerSettingsService } from "../../serverSettings.ts";
2828import { ProviderDriverError } from "../Errors.ts";
2929import { makeOpenCodeAdapter } from "../Layers/OpenCodeAdapter.ts";
30import { readOpenCodeGoUsageLimits } from "../Layers/openCodeUsageLimits.ts";
3031import {
3132 checkOpenCodeProviderStatus,
3233 makePendingOpenCodeProvider,
......@@ -148,12 +149,22 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
148149 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
149150 );
150151
151 const checkProvider = checkOpenCodeProviderStatus(
152 effectiveConfig,
153 serverConfig.cwd,
154 processEnv,
152 const checkProvider = Effect.all(
153 {
154 provider: checkOpenCodeProviderStatus(effectiveConfig, serverConfig.cwd, processEnv),
155 usageLimits: readOpenCodeGoUsageLimits({
156 enabled: effectiveConfig.enabled,
157 serverUrl: effectiveConfig.serverUrl,
158 environment: processEnv,
159 }),
160 },
161 { concurrency: "unbounded" },
155162 ).pipe(
163 Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })),
156164 Effect.map(stampIdentity),
165 Effect.provideService(FileSystem.FileSystem, fileSystem),
166 Effect.provideService(Path.Path, pathService),
167 Effect.provideService(HttpClient.HttpClient, httpClient),
157168 Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner),
158169 Effect.provideService(OpenCodeRuntime, openCodeRuntime),
159170 );
apps/server/src/provider/Layers/CursorProvider.test.ts+170
......@@ -35,6 +35,8 @@ import {
3535} from "../Drivers/CursorSkills.ts";
3636import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
3737import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
38import { HttpClient, HttpClientResponse } from "effect/unstable/http";
39import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts";
3840
3941const runNode = <A, E>(
4042 effect: Effect.Effect<
......@@ -952,3 +954,171 @@ describe("resolveCursorAcpConfigUpdates", () => {
952954 ]);
953955 });
954956});
957
958describe("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";
77import * as Effect from "effect/Effect";
88import * as FileSystem from "effect/FileSystem";
99import * as Schema from "effect/Schema";
10import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1011import { GrokSettings } from "@t3tools/contracts";
1112
1213import {
......@@ -18,6 +19,7 @@ import {
1819 parseGrokModelsCliOutput,
1920} from "./GrokProvider.ts";
2021import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts";
22import { grokUsageResponseToLimits, readGrokUsageLimits } from "./grokUsageLimits.ts";
2123
2224const decodeGrokSettings = Schema.decodeSync(GrokSettings);
2325const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url));
......@@ -530,3 +532,252 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => {
530532 }),
531533 );
532534});
535
536describe("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
586it.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";
44import { it } from "@effect/vitest";
55import * as Effect from "effect/Effect";
66import * as Fiber from "effect/Fiber";
7import * as FileSystem from "effect/FileSystem";
78import * as Layer from "effect/Layer";
89import * as Schema from "effect/Schema";
910import * as TestClock from "effect/testing/TestClock";
11import { HttpClient, HttpClientResponse } from "effect/unstable/http";
1012import { beforeEach } from "vite-plus/test";
1113
1214import { OpenCodeSettings } from "@t3tools/contracts";
......@@ -23,10 +25,125 @@ import {
2325 openCodeCommandsToServerProviderSlashCommands,
2426} from "./OpenCodeProvider.ts";
2527import type { OpenCodeInventory } from "../opencodeRuntime.ts";
28import { readOpenCodeGoUsageLimits } from "./openCodeUsageLimits.ts";
2629const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings);
2730
2831const DEFAULT_VERSION_STDOUT = "opencode 1.14.19\n";
2932
33it.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
89it.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
113it.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
30147/**
31148 * The legacy `OpenCodeProviderLive` Layer + `OpenCodeProvider` service tag
32149 * 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 @@
1import * as NodeOS from "node:os";
2import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts";
3import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
11import {
12 clampPercent,
13 makeUnavailableUsageLimits,
14 makeUsageLimits,
15} from "../providerUsageLimits.ts";
16
17const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) });
18const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials));
19const 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. */
31export 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
63export 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 @@
1import * as NodeOS from "node:os";
2import type { ServerProviderUsageWindow } from "@t3tools/contracts";
3import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Option from "effect/Option";
7import * as Path from "effect/Path";
8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
10import {
11 clampPercent,
12 makeUnavailableUsageLimits,
13 makeUsageLimits,
14} from "../providerUsageLimits.ts";
15
16const GrokCredentials = Schema.Record(
17 Schema.String,
18 Schema.Struct({
19 key: Schema.optional(Schema.String),
20 auth_mode: Schema.optional(Schema.String),
21 }),
22);
23const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials));
24const 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
38export 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
60export 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 @@
1import * as NodeOS from "node:os";
2
3import type { ServerProviderUsageWindow } from "@t3tools/contracts";
4import * as DateTime from "effect/DateTime";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
11
12import {
13 clampPercent,
14 makeUnavailableUsageLimits,
15 makeUsageLimits,
16} from "../providerUsageLimits.ts";
17
18const AuthFile = Schema.Struct({ "opencode-go": Schema.optionalKey(Schema.Unknown) });
19const ApiAuth = Schema.Struct({ type: Schema.Literal("api"), key: Schema.String });
20const decodeAuthFile = Schema.decodeEffect(Schema.fromJsonString(AuthFile));
21const decodeApiAuth = Schema.decodeUnknownOption(ApiAuth);
22const UsageWindow = Schema.Struct({
23 percent: Schema.Finite,
24 resetsAt: Schema.DateTimeUtcFromString,
25});
26const 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. */
31export 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
6969closes 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
7070anything. The command is offered only for providers that appear under **Usage → Limits**.
7171
72OpenCode Go reports its session, weekly, and monthly allowance when OpenCode runs locally in
73the environment. T3 cannot report limits for external OpenCode servers because their credentials
74belong to the remote server. Cursor reports
75its 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.
77On macOS, use `AGENT_CLI_CREDENTIAL_STORE=file` when signing in and in the provider's environment
78to use a file-based login.
79
80Grok reports the remaining subscription allowance and reset time for its current billing period
81after signing in with `grok login`. Explicit `XAI_API_KEY` connections and custom authentication
82or endpoint configurations do not report subscription limits.
83
7284API-key accounts may not report subscription limits. This also applies to Claude connections
7385using a proxy through `ANTHROPIC_AUTH_TOKEN`.
7486