| author | |
| committer | |
| log | 91e53e501c9e7eb03f0ea4a5b72221d08eff9c5c |
| tree | 3d54c5aa77cab3ba567cb7b45e1d57ff9c25fb6c |
| parent | 0cab7d5ab978a8adaee6827f81693249d0dfeee5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com>3 files changed, 170 insertions(+), 87 deletions(-)
apps/server/src/provider/Drivers/GrokDriver.ts+8-3| ... | ... | @@ -19,7 +19,7 @@ import { |
| 19 | 19 | enrichGrokSnapshot, |
| 20 | 20 | } from "../Layers/GrokProvider.ts"; |
| 21 | 21 | import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; |
| 22 | import { readGrokUsageLimits } from "../Layers/grokUsageLimits.ts"; | |
| 22 | import { readGrokAccount } from "../Layers/grokUsageLimits.ts"; | |
| 23 | 23 | import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; |
| 24 | 24 | import { |
| 25 | 25 | defaultProviderContinuationIdentity, |
| ... | ... | @@ -133,8 +133,13 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = { |
| 133 | 133 | const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( |
| 134 | 134 | Effect.flatMap((snapshot) => |
| 135 | 135 | effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" |
| 136 | ? readGrokUsageLimits(processEnv).pipe( | |
| 137 | Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), | |
| 136 | ? readGrokAccount(processEnv).pipe( | |
| 137 | // The email lets clients recognize one account signed in on several environments. | |
| 138 | Effect.map(({ email, usageLimits }) => ({ | |
| 139 | ...snapshot, | |
| 140 | auth: email ? { ...snapshot.auth, email } : snapshot.auth, | |
| 141 | usageLimits, | |
| 142 | })), | |
| 138 | 143 | ) |
| 139 | 144 | : Effect.succeed(snapshot), |
| 140 | 145 | ), |
apps/server/src/provider/Layers/GrokProvider.test.ts+71-12| ... | ... | @@ -19,7 +19,7 @@ import { |
| 19 | 19 | parseGrokModelsCliOutput, |
| 20 | 20 | } from "./GrokProvider.ts"; |
| 21 | 21 | import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; |
| 22 | import { grokUsageResponseToLimits, readGrokUsageLimits } from "./grokUsageLimits.ts"; | |
| 22 | import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; | |
| 23 | 23 | |
| 24 | 24 | const decodeGrokSettings = Schema.decodeSync(GrokSettings); |
| 25 | 25 | const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); |
| ... | ... | @@ -586,7 +586,7 @@ describe("Grok usage limits", () => { |
| 586 | 586 | }); |
| 587 | 587 | }); |
| 588 | 588 | |
| 589 | it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { | |
| 589 | it.layer(NodeServices.layer)("readGrokAccount", (it) => { | |
| 590 | 590 | it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => |
| 591 | 591 | Effect.gen(function* () { |
| 592 | 592 | const fs = yield* FileSystem.FileSystem; |
| ... | ... | @@ -595,7 +595,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 595 | 595 | NodePath.join(directory, "auth.json"), |
| 596 | 596 | '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', |
| 597 | 597 | ); |
| 598 | const limits = yield* readGrokUsageLimits({ | |
| 598 | const { usageLimits: limits } = yield* readGrokAccount({ | |
| 599 | 599 | GROK_HOME: directory, |
| 600 | 600 | HOME: "/unrelated-home", |
| 601 | 601 | }).pipe( |
| ... | ... | @@ -621,7 +621,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 621 | 621 | it.effect( |
| 622 | 622 | "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", |
| 623 | 623 | () => |
| 624 | readGrokUsageLimits({ | |
| 624 | readGrokAccount({ | |
| 625 | 625 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', |
| 626 | 626 | }).pipe( |
| 627 | 627 | Effect.provideService( |
| ... | ... | @@ -645,7 +645,9 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 645 | 645 | ); |
| 646 | 646 | }), |
| 647 | 647 | ), |
| 648 | Effect.tap((limits) => Effect.sync(() => expect(limits.windows[0]?.usedPercent).toBe(12))), | |
| 648 | Effect.tap(({ usageLimits }) => | |
| 649 | Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)), | |
| 650 | ), | |
| 649 | 651 | ), |
| 650 | 652 | ); |
| 651 | 653 | |
| ... | ... | @@ -684,7 +686,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 684 | 686 | GROK_CONFIG_PATH: "/custom-config.toml", |
| 685 | 687 | }, |
| 686 | 688 | ]) { |
| 687 | const limits = yield* readGrokUsageLimits({ | |
| 689 | const { usageLimits: limits } = yield* readGrokAccount({ | |
| 688 | 690 | HOME: "/definitely/not/a/grok-home", |
| 689 | 691 | ...environment, |
| 690 | 692 | }).pipe( |
| ... | ... | @@ -706,7 +708,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 706 | 708 | const fs = yield* FileSystem.FileSystem; |
| 707 | 709 | const directory = yield* fs.makeTempDirectoryScoped(); |
| 708 | 710 | const client = HttpClient.make(() => Effect.die("must not request without credentials")); |
| 709 | const missing = yield* readGrokUsageLimits({ HOME: directory }).pipe( | |
| 711 | const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe( | |
| 710 | 712 | Effect.provideService(HttpClient.HttpClient, client), |
| 711 | 713 | ); |
| 712 | 714 | expect(missing.unavailable?.reason).toBe("unsupported"); |
| ... | ... | @@ -714,7 +716,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 714 | 716 | "private-token-invalid-json", |
| 715 | 717 | '{"https://accounts.x.ai/sign-in":{"key":42}}', |
| 716 | 718 | ]) { |
| 717 | const malformed = yield* readGrokUsageLimits({ | |
| 719 | const { usageLimits: malformed } = yield* readGrokAccount({ | |
| 718 | 720 | GROK_HOME: directory, |
| 719 | 721 | GROK_AUTH: contents, |
| 720 | 722 | }).pipe(Effect.provideService(HttpClient.HttpClient, client)); |
| ... | ... | @@ -735,7 +737,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 735 | 737 | '[grok_com_config]\nissuer = "https://custom.example"', |
| 736 | 738 | 'endpoints.proxy = "https://custom.example"', |
| 737 | 739 | ]) { |
| 738 | const limits = yield* readGrokUsageLimits({ | |
| 740 | const { usageLimits: limits } = yield* readGrokAccount({ | |
| 739 | 741 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', |
| 740 | 742 | }).pipe( |
| 741 | 743 | Effect.provideService( |
| ... | ... | @@ -758,15 +760,16 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 758 | 760 | }), |
| 759 | 761 | ); |
| 760 | 762 | |
| 761 | it.effect("sanitizes HTTP failures and malformed billing responses", () => | |
| 763 | it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => | |
| 762 | 764 | Effect.gen(function* () { |
| 763 | 765 | for (const response of [ |
| 764 | 766 | new Response("private response", { status: 401 }), |
| 765 | 767 | Response.json({ config: { creditUsagePercent: "private-value" } }), |
| 766 | 768 | ]) { |
| 767 | const limits = yield* readGrokUsageLimits({ | |
| 769 | const { email, usageLimits: limits } = yield* readGrokAccount({ | |
| 768 | 770 | HOME: "/definitely/not/a/grok-home", |
| 769 | GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"private-token"}}', | |
| 771 | GROK_AUTH: | |
| 772 | '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}', | |
| 770 | 773 | }).pipe( |
| 771 | 774 | Effect.provideService( |
| 772 | 775 | HttpClient.HttpClient, |
| ... | ... | @@ -775,6 +778,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 775 | 778 | ), |
| 776 | 779 | ), |
| 777 | 780 | ); |
| 781 | expect(email).toBe("someone@example.com"); | |
| 778 | 782 | expect(limits.windows).toEqual([]); |
| 779 | 783 | expect(limits.unavailable).toEqual({ |
| 780 | 784 | reason: "probeFailed", |
| ... | ... | @@ -784,3 +788,58 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { |
| 784 | 788 | }), |
| 785 | 789 | ); |
| 786 | 790 | }); |
| 791 | ||
| 792 | it.layer(NodeServices.layer)("readGrokAccount email", (it) => { | |
| 793 | it.effect("names the account whose limits were read, and nothing for other auth", () => | |
| 794 | Effect.gen(function* () { | |
| 795 | const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; | |
| 796 | const cases = [ | |
| 797 | [ | |
| 798 | { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` }, | |
| 799 | "Someone@Example.com", | |
| 800 | ], | |
| 801 | [ | |
| 802 | { | |
| 803 | GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`, | |
| 804 | }, | |
| 805 | "current@example.com", | |
| 806 | ], | |
| 807 | [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined], | |
| 808 | [ | |
| 809 | { | |
| 810 | GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`, | |
| 811 | XAI_API_KEY: "api-key", | |
| 812 | }, | |
| 813 | undefined, | |
| 814 | ], | |
| 815 | [ | |
| 816 | { | |
| 817 | GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`, | |
| 818 | }, | |
| 819 | undefined, | |
| 820 | ], | |
| 821 | [{ GROK_AUTH: "not-json" }, undefined], | |
| 822 | ] as const; | |
| 823 | for (const [environment, expected] of cases) { | |
| 824 | const { email, usageLimits } = yield* readGrokAccount({ | |
| 825 | HOME: "/definitely/not/a/grok-home", | |
| 826 | ...environment, | |
| 827 | }).pipe( | |
| 828 | Effect.provideService( | |
| 829 | HttpClient.HttpClient, | |
| 830 | HttpClient.make((request) => | |
| 831 | Effect.succeed( | |
| 832 | HttpClientResponse.fromWeb( | |
| 833 | request, | |
| 834 | Response.json({ config: { creditUsagePercent: 25 } }), | |
| 835 | ), | |
| 836 | ), | |
| 837 | ), | |
| 838 | ), | |
| 839 | ); | |
| 840 | expect(email).toBe(expected); | |
| 841 | if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25); | |
| 842 | } | |
| 843 | }), | |
| 844 | ); | |
| 845 | }); |
apps/server/src/provider/Layers/grokUsageLimits.ts+91-72| ... | ... | @@ -18,6 +18,7 @@ const GrokCredentials = Schema.Record( |
| 18 | 18 | Schema.Struct({ |
| 19 | 19 | key: Schema.optional(Schema.String), |
| 20 | 20 | auth_mode: Schema.optional(Schema.String), |
| 21 | email: Schema.optional(Schema.String), | |
| 21 | 22 | }), |
| 22 | 23 | ); |
| 23 | 24 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); |
| ... | ... | @@ -65,72 +66,97 @@ export function grokUsageResponseToLimits( |
| 65 | 66 | return makeUsageLimits({ checkedAt, windows: [window] }); |
| 66 | 67 | } |
| 67 | 68 | |
| 68 | export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( | |
| 69 | /** | |
| 70 | * The grok.com login the CLI uses by default, or undefined when the CLI is | |
| 71 | * configured to pick another account, endpoint, or an API key. | |
| 72 | */ | |
| 73 | const readGrokCredential = Effect.fn("readGrokCredential")(function* ( | |
| 74 | environment: NodeJS.ProcessEnv, | |
| 75 | ) { | |
| 76 | // T3's ACP adapter explicitly selects API-key auth when this variable is set. | |
| 77 | if (environment.XAI_API_KEY?.trim()) return undefined; | |
| 78 | // Alternate auth deployments can select another scope or account from the same file. | |
| 79 | if ( | |
| 80 | [ | |
| 81 | "GROK_OIDC_ISSUER", | |
| 82 | "GROK_OIDC_CLIENT_ID", | |
| 83 | "GROK_OAUTH2_ISSUER", | |
| 84 | "GROK_OAUTH2_CLIENT_ID", | |
| 85 | "GROK_OAUTH2_PRINCIPAL_TYPE", | |
| 86 | "GROK_OAUTH2_PRINCIPAL_ID", | |
| 87 | "GROK_AUTH_PROVIDER_COMMAND", | |
| 88 | "GROK_LOCAL_AUTH", | |
| 89 | "GROK_CLI_CHAT_PROXY_BASE_URL", | |
| 90 | "GROK_MODELS_BASE_URL", | |
| 91 | "GROK_CONFIG", | |
| 92 | "GROK_CONFIG_PATH", | |
| 93 | ].some((name) => environment[name]?.trim()) | |
| 94 | ) { | |
| 95 | return undefined; | |
| 96 | } | |
| 97 | const fs = yield* FileSystem.FileSystem; | |
| 98 | const path = yield* Path.Path; | |
| 99 | const home = | |
| 100 | environment.GROK_HOME?.trim() || | |
| 101 | path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); | |
| 102 | for (const configPath of [ | |
| 103 | path.join(home, "config.toml"), | |
| 104 | path.join(home, "managed_config.toml"), | |
| 105 | path.join(home, "requirements.toml"), | |
| 106 | "/etc/grok/managed_config.toml", | |
| 107 | "/etc/grok/requirements.toml", | |
| 108 | ]) { | |
| 109 | const config = yield* fs.readFileString(configPath).pipe( | |
| 110 | Effect.catchTags({ | |
| 111 | PlatformError: (error) => | |
| 112 | error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), | |
| 113 | }), | |
| 114 | ); | |
| 115 | // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. | |
| 116 | if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { | |
| 117 | return undefined; | |
| 118 | } | |
| 119 | } | |
| 120 | const contents = | |
| 121 | environment.GROK_AUTH?.trim() || | |
| 122 | (yield* fs.readFileString(path.join(home, "auth.json")).pipe( | |
| 123 | Effect.catchTags({ | |
| 124 | PlatformError: (error) => | |
| 125 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | |
| 126 | }), | |
| 127 | )); | |
| 128 | const credentials = yield* decodeCredentials(contents); | |
| 129 | // Never pick an arbitrary account from other deployments stored in the same file. | |
| 130 | const credential = | |
| 131 | credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? | |
| 132 | credentials["https://accounts.x.ai/sign-in"]; | |
| 133 | return credential?.auth_mode === "api_key" ? undefined : credential; | |
| 134 | }); | |
| 135 | ||
| 136 | /** | |
| 137 | * Reads the default grok.com login once and reports its usage limits along with | |
| 138 | * its email, so the email always names the account whose quota was read. | |
| 139 | */ | |
| 140 | export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( | |
| 69 | 141 | environment: NodeJS.ProcessEnv = process.env, |
| 70 | 142 | ) { |
| 71 | 143 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 72 | return yield* Effect.gen(function* () { | |
| 73 | // T3's ACP adapter explicitly selects API-key auth when this variable is set. | |
| 74 | if (environment.XAI_API_KEY?.trim()) { | |
| 75 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 76 | } | |
| 77 | // Alternate auth deployments can select another scope or account from the same file. | |
| 78 | if ( | |
| 79 | [ | |
| 80 | "GROK_OIDC_ISSUER", | |
| 81 | "GROK_OIDC_CLIENT_ID", | |
| 82 | "GROK_OAUTH2_ISSUER", | |
| 83 | "GROK_OAUTH2_CLIENT_ID", | |
| 84 | "GROK_OAUTH2_PRINCIPAL_TYPE", | |
| 85 | "GROK_OAUTH2_PRINCIPAL_ID", | |
| 86 | "GROK_AUTH_PROVIDER_COMMAND", | |
| 87 | "GROK_LOCAL_AUTH", | |
| 88 | "GROK_CLI_CHAT_PROXY_BASE_URL", | |
| 89 | "GROK_MODELS_BASE_URL", | |
| 90 | "GROK_CONFIG", | |
| 91 | "GROK_CONFIG_PATH", | |
| 92 | ].some((name) => environment[name]?.trim()) | |
| 93 | ) { | |
| 94 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 95 | } | |
| 96 | const fs = yield* FileSystem.FileSystem; | |
| 97 | const path = yield* Path.Path; | |
| 98 | const home = | |
| 99 | environment.GROK_HOME?.trim() || | |
| 100 | path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); | |
| 101 | for (const configPath of [ | |
| 102 | path.join(home, "config.toml"), | |
| 103 | path.join(home, "managed_config.toml"), | |
| 104 | path.join(home, "requirements.toml"), | |
| 105 | "/etc/grok/managed_config.toml", | |
| 106 | "/etc/grok/requirements.toml", | |
| 107 | ]) { | |
| 108 | const config = yield* fs.readFileString(configPath).pipe( | |
| 109 | Effect.catchTags({ | |
| 110 | PlatformError: (error) => | |
| 111 | error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), | |
| 112 | }), | |
| 113 | ); | |
| 114 | // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. | |
| 115 | if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { | |
| 116 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 117 | } | |
| 118 | } | |
| 119 | const contents = | |
| 120 | environment.GROK_AUTH?.trim() || | |
| 121 | (yield* fs.readFileString(path.join(home, "auth.json")).pipe( | |
| 122 | Effect.catchTags({ | |
| 123 | PlatformError: (error) => | |
| 124 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | |
| 125 | }), | |
| 126 | )); | |
| 127 | const credentials = yield* decodeCredentials(contents); | |
| 128 | // Never pick an arbitrary account from other deployments stored in the same file. | |
| 129 | const credential = | |
| 130 | credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? | |
| 131 | credentials["https://accounts.x.ai/sign-in"]; | |
| 132 | const token = credential?.auth_mode === "api_key" ? undefined : credential?.key?.trim(); | |
| 133 | if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 144 | const probeFailed = makeUnavailableUsageLimits({ | |
| 145 | checkedAt, | |
| 146 | reason: "probeFailed", | |
| 147 | message: "Grok could not read usage limits.", | |
| 148 | }); | |
| 149 | const credential = yield* Effect.option( | |
| 150 | readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), | |
| 151 | ); | |
| 152 | if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; | |
| 153 | const email = credential.value?.email?.trim() || undefined; | |
| 154 | const token = credential.value?.key?.trim(); | |
| 155 | if (!token) { | |
| 156 | return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; | |
| 157 | } | |
| 158 | // A failed quota request still knows which account it asked about. | |
| 159 | const usageLimits = yield* Effect.gen(function* () { | |
| 134 | 160 | const client = yield* HttpClient.HttpClient; |
| 135 | 161 | const response = yield* client.execute( |
| 136 | 162 | HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( |
| ... | ... | @@ -143,14 +169,7 @@ export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( |
| 143 | 169 | return grokUsageResponseToLimits(body, checkedAt); |
| 144 | 170 | }).pipe( |
| 145 | 171 | Effect.timeout("10 seconds"), |
| 146 | Effect.catch(() => | |
| 147 | Effect.succeed( | |
| 148 | makeUnavailableUsageLimits({ | |
| 149 | checkedAt, | |
| 150 | reason: "probeFailed", | |
| 151 | message: "Grok could not read usage limits.", | |
| 152 | }), | |
| 153 | ), | |
| 154 | ), | |
| 172 | Effect.catch(() => Effect.succeed(probeFailed)), | |
| 155 | 173 | ); |
| 174 | return { email, usageLimits }; | |
| 156 | 175 | }); |