@gajae-code/ai 0.2.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-06-03
6
+
7
+ ### Added
8
+
9
+ - Added xAI to the `/login` provider catalog as a Grok OAuth login with PKCE, refresh-token storage, and mocked login/refresh coverage.
10
+
5
11
  ## [0.2.4] - 2026-06-02
6
12
 
7
13
  ### Added
package/README.md CHANGED
@@ -1014,6 +1014,7 @@ Several providers support OAuth authentication (some also support static API key
1014
1014
  - **Google Gemini CLI** (Gemini 2.0/2.5 via Google Cloud Code Assist; free tier or paid subscription)
1015
1015
  - **Antigravity** (Free Gemini 3, Anthropic model, GPT-OSS via Google Cloud)
1016
1016
  - **Qwen Portal** (Qwen OAuth token or API key)
1017
+ - **xAI** (Grok OAuth login via xAI account)
1017
1018
 
1018
1019
  For paid Cloud Code Assist subscriptions, set `GOOGLE_CLOUD_PROJECT` or `GOOGLE_CLOUD_PROJECT_ID` to your project ID.
1019
1020
 
@@ -1063,12 +1064,13 @@ The quickest way to authenticate:
1063
1064
  bunx @gajae-code/ai login # interactive provider selection
1064
1065
  bunx @gajae-code/ai login anthropic # login to specific provider
1065
1066
  bunx @gajae-code/ai login vllm # store vLLM API key (or placeholder for local no-auth)
1067
+ bunx @gajae-code/ai login xai # sign in with xAI/Grok OAuth
1066
1068
  bunx @gajae-code/ai list # list available providers
1067
1069
  ```
1068
1070
 
1069
- Credentials are saved to `agent.db` in the agent directory. `/login qianfan` opens the Qianfan console and stores the pasted API key.
1071
+ Credentials are saved to `agent.db` in the agent directory. `/login qianfan` opens the Qianfan console and stores the pasted API key; `/login xai` opens xAI/Grok OAuth login and stores refreshable OAuth credentials.
1070
1072
 
1071
- `login` supports OAuth providers (Anthropic, OpenAI code provider, GitHub Copilot, Gemini CLI, Antigravity) and API-key onboarding flows.
1073
+ `login` supports OAuth providers (Anthropic, OpenAI code provider, GitHub Copilot, Gemini CLI, Antigravity, xAI) and API-key onboarding flows.
1072
1074
 
1073
1075
  For the current API-key onboarding flows, the library covers Together, Moonshot, Qianfan, NVIDIA, NanoGPT, Hugging Face, Venice, Xiaomi, vLLM, LiteLLM, Cloudflare AI Gateway, Qwen Portal, and Ollama Cloud. Ollama remains the local runtime integration; set `OLLAMA_API_KEY` only when your local or self-hosted deployment enforces bearer auth.
1074
1076
 
@@ -7,6 +7,8 @@ export interface OAuthCallbackFlowOptions {
7
7
  preferredPort: number;
8
8
  callbackPath?: string;
9
9
  callbackHostname?: string;
10
+ /** Local listener hostname; defaults to callbackHostname when omitted. */
11
+ callbackBindHostname?: string;
10
12
  /** Exact redirect URI advertised to the provider; disables port fallback. */
11
13
  redirectUri?: string;
12
14
  }
@@ -19,6 +21,7 @@ export declare abstract class OAuthCallbackFlow {
19
21
  preferredPort: number;
20
22
  callbackPath: string;
21
23
  callbackHostname: string;
24
+ callbackBindHostname: string;
22
25
  redirectUri?: string;
23
26
  constructor(ctrl: OAuthController, preferredPortOrOptions: number | OAuthCallbackFlowOptions, callbackPath?: string);
24
27
  /**
@@ -7,7 +7,7 @@ export type OAuthCredentials = {
7
7
  email?: string;
8
8
  accountId?: string;
9
9
  };
10
- export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xiaomi" | "zenmux" | "zai";
10
+ export type OAuthProvider = "alibaba-coding-plan" | "anthropic" | "cerebras" | "cloudflare-ai-gateway" | "cursor" | "deepseek" | "fireworks" | "firepass" | "github-copilot" | "google-gemini-cli" | "google-antigravity" | "gitlab-duo" | "huggingface" | "kimi-code" | "kilo" | "kagi" | "litellm" | "lm-studio" | "minimax-code" | "minimax-code-cn" | "moonshot" | "nvidia" | "nanogpt" | "ollama" | "ollama-cloud" | "openai-codex" | "openai-codex-device" | "opencode-go" | "opencode-zen" | "parallel" | "perplexity" | "qianfan" | "qwen-portal" | "synthetic" | "tavily" | "together" | "venice" | "vercel-ai-gateway" | "vllm" | "xai" | "xiaomi" | "zenmux" | "zai";
11
11
  export type OAuthProviderId = OAuthProvider | (string & {});
12
12
  export type OAuthPrompt = {
13
13
  message: string;
@@ -0,0 +1,23 @@
1
+ /** xAI OAuth flow (Grok account login). */
2
+ import { OAuthCallbackFlow } from "./callback-server";
3
+ import type { OAuthController, OAuthCredentials } from "./types";
4
+ export declare const XAI_OAUTH_DISCOVERY_URL = "https://auth.x.ai/.well-known/openid-configuration";
5
+ export declare const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
6
+ export declare const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
7
+ interface XaiDiscovery {
8
+ authorizationEndpoint: string;
9
+ tokenEndpoint: string;
10
+ }
11
+ export declare function discoverXaiOAuthEndpoints(signal?: AbortSignal): Promise<XaiDiscovery>;
12
+ export declare class XaiOAuthFlow extends OAuthCallbackFlow {
13
+ #private;
14
+ constructor(ctrl: OAuthController);
15
+ generateAuthUrl(state: string, redirectUri: string): Promise<{
16
+ url: string;
17
+ instructions?: string;
18
+ }>;
19
+ exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials>;
20
+ }
21
+ export declare function loginXai(ctrl: OAuthController): Promise<OAuthCredentials>;
22
+ export declare function refreshXaiToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials>;
23
+ export {};
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.2.4",
4
+ "version": "0.3.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gaebal-gajae.dev",
7
7
  "author": "Yeachan-Heo",
@@ -43,7 +43,7 @@
43
43
  "dependencies": {
44
44
  "@anthropic-ai/sdk": "^0.94.0",
45
45
  "@bufbuild/protobuf": "^2.12.0",
46
- "@gajae-code/utils": "0.2.4",
46
+ "@gajae-code/utils": "0.3.0",
47
47
  "openai": "^6.36.0",
48
48
  "partial-json": "^0.1.7",
49
49
  "zod": "4.4.3"
@@ -281,7 +281,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
281
281
 
282
282
  async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
283
283
  const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
284
- if (!entry || entry.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
284
+ if (entry?.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
285
285
  const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
286
286
  if (remainingMs > WAIT_THRESHOLD_MS) return false;
287
287
  return this.waitForFreshSnapshot(MAX_WAIT_MS, opts);
@@ -1484,6 +1484,14 @@ export class AuthStorage {
1484
1484
  await saveApiKeyCredential(apiKey);
1485
1485
  return;
1486
1486
  }
1487
+ case "xai": {
1488
+ const { loginXai } = await import("./utils/oauth/xai");
1489
+ credentials = await loginXai({
1490
+ ...ctrl,
1491
+ onManualCodeInput: ctrl.onManualCodeInput ?? manualCodeInput,
1492
+ });
1493
+ break;
1494
+ }
1487
1495
  case "fireworks": {
1488
1496
  const { loginFireworks } = await import("./utils/oauth/fireworks");
1489
1497
  const apiKey = await loginFireworks(ctrl);
@@ -1637,6 +1645,13 @@ export class AuthStorage {
1637
1645
  }
1638
1646
  }
1639
1647
  const newCredential: OAuthCredential = { type: "oauth", ...credentials };
1648
+ if (provider === "xai") {
1649
+ const existingOAuthCredentials = this.#getCredentialsForProvider(provider).filter(
1650
+ (credential): credential is OAuthCredential => credential.type === "oauth",
1651
+ );
1652
+ await this.set(provider, [...existingOAuthCredentials, newCredential]);
1653
+ return;
1654
+ }
1640
1655
  await this.#upsertOAuthCredential(provider, newCredential);
1641
1656
  }
1642
1657
 
@@ -2733,7 +2748,7 @@ export class AuthStorage {
2733
2748
  if (!prepare) return true;
2734
2749
  const stored = this.#getStoredCredentials(provider);
2735
2750
  const selected = stored[selection.index];
2736
- if (!selected || selected.credential.type !== "oauth") return false;
2751
+ if (selected?.credential.type !== "oauth") return false;
2737
2752
 
2738
2753
  const prepared = await prepare(selected.id, { signal: options?.signal });
2739
2754
  if (!prepared) return true;
@@ -2745,7 +2760,7 @@ export class AuthStorage {
2745
2760
  const latestIndex = latestRows.findIndex(row => row.id === selected.id);
2746
2761
  if (latestIndex === -1) return false;
2747
2762
  const latest = latestRows[latestIndex];
2748
- if (!latest || latest.credential.type !== "oauth") return false;
2763
+ if (latest?.credential.type !== "oauth") return false;
2749
2764
  selection.index = latestIndex;
2750
2765
  selection.credential = latest.credential;
2751
2766
  return true;
package/src/cli.ts CHANGED
@@ -110,6 +110,7 @@ Providers:
110
110
  tavily Tavily
111
111
  zai Z.AI (GLM Coding Plan)
112
112
  deepseek DeepSeek
113
+ xai xAI
113
114
  nanogpt NanoGPT
114
115
  minimax-code MiniMax Coding Plan (International)
115
116
  minimax-code-cn MiniMax Coding Plan (China)
@@ -1684,7 +1684,7 @@ function disableThinkingIfToolChoiceForced(params: MessageCreateParamsStreaming)
1684
1684
 
1685
1685
  function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model: Model<"anthropic-messages">): void {
1686
1686
  const thinking = params.thinking;
1687
- if (!thinking || thinking.type !== "enabled") return;
1687
+ if (thinking?.type !== "enabled") return;
1688
1688
 
1689
1689
  const budgetTokens = thinking.budget_tokens ?? 0;
1690
1690
  if (budgetTokens <= 0) return;
@@ -554,7 +554,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
554
554
  eventStream: AssistantMessageEventStream,
555
555
  text: string,
556
556
  ): void => {
557
- if (!currentBlock || currentBlock.type !== "text") {
557
+ if (currentBlock?.type !== "text") {
558
558
  finishCurrentBlock(currentBlock);
559
559
  currentBlock = { type: "text", text: "" };
560
560
  message.content.push(currentBlock);
@@ -575,8 +575,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
575
575
  signature?: string,
576
576
  ): void => {
577
577
  if (
578
- !currentBlock ||
579
- currentBlock.type !== "thinking" ||
578
+ currentBlock?.type !== "thinking" ||
580
579
  (signature !== undefined && currentBlock.thinkingSignature !== signature)
581
580
  ) {
582
581
  finishCurrentBlock(currentBlock);
@@ -786,11 +785,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
786
785
 
787
786
  if (choice?.delta?.tool_calls && choice.delta.tool_calls.length > 0) {
788
787
  for (const toolCall of choice.delta.tool_calls) {
789
- if (
790
- !currentBlock ||
791
- currentBlock.type !== "toolCall" ||
792
- (toolCall.id && currentBlock.id !== toolCall.id)
793
- ) {
788
+ if (currentBlock?.type !== "toolCall" || (toolCall.id && currentBlock.id !== toolCall.id)) {
794
789
  finishCurrentBlock(currentBlock);
795
790
  currentBlock = {
796
791
  type: "toolCall",
@@ -954,7 +954,7 @@ export function encodeStream(
954
954
  break;
955
955
  }
956
956
  case "text_delta": {
957
- if (!state.open || state.open.kind !== "message") break;
957
+ if (state.open?.kind !== "message") break;
958
958
  const cur: OpenMessage = state.open;
959
959
  cur.currentPartText += ev.delta;
960
960
  emit("response.output_text.delta", {
@@ -970,7 +970,7 @@ export function encodeStream(
970
970
  break;
971
971
  }
972
972
  case "text_end": {
973
- if (!state.open || state.open.kind !== "message") break;
973
+ if (state.open?.kind !== "message") break;
974
974
  const cur: OpenMessage = state.open;
975
975
  const text = ev.content ?? cur.currentPartText;
976
976
  emit("response.output_text.done", {
@@ -997,7 +997,7 @@ export function encodeStream(
997
997
  break;
998
998
  }
999
999
  case "thinking_delta": {
1000
- if (!state.open || state.open.kind !== "reasoning") break;
1000
+ if (state.open?.kind !== "reasoning") break;
1001
1001
  const cur: OpenReasoning = state.open;
1002
1002
  cur.reasoningText += ev.delta;
1003
1003
  emit("response.reasoning_summary_text.delta", {
@@ -1009,7 +1009,7 @@ export function encodeStream(
1009
1009
  break;
1010
1010
  }
1011
1011
  case "thinking_end": {
1012
- if (!state.open || state.open.kind !== "reasoning") break;
1012
+ if (state.open?.kind !== "reasoning") break;
1013
1013
  const cur: OpenReasoning = state.open;
1014
1014
  const text = ev.content ?? cur.reasoningText;
1015
1015
  cur.reasoningText = text;
@@ -1034,7 +1034,7 @@ export function encodeStream(
1034
1034
  break;
1035
1035
  }
1036
1036
  case "toolcall_delta": {
1037
- if (!state.open || state.open.kind !== "function_call") break;
1037
+ if (state.open?.kind !== "function_call") break;
1038
1038
  const cur: OpenFunctionCall = state.open;
1039
1039
  cur.argsText += ev.delta;
1040
1040
  if (cur.customWireName) {
@@ -1053,7 +1053,7 @@ export function encodeStream(
1053
1053
  break;
1054
1054
  }
1055
1055
  case "toolcall_end": {
1056
- if (!state.open || state.open.kind !== "function_call") break;
1056
+ if (state.open?.kind !== "function_call") break;
1057
1057
  const cur: OpenFunctionCall = state.open;
1058
1058
  // Promote possibly-late info from the canonical ToolCall.
1059
1059
  const tc = ev.toolCall;
@@ -23,6 +23,8 @@ export interface OAuthCallbackFlowOptions {
23
23
  preferredPort: number;
24
24
  callbackPath?: string;
25
25
  callbackHostname?: string;
26
+ /** Local listener hostname; defaults to callbackHostname when omitted. */
27
+ callbackBindHostname?: string;
26
28
  /** Exact redirect URI advertised to the provider; disables port fallback. */
27
29
  redirectUri?: string;
28
30
  }
@@ -35,6 +37,7 @@ export abstract class OAuthCallbackFlow {
35
37
  preferredPort: number;
36
38
  callbackPath: string;
37
39
  callbackHostname: string;
40
+ callbackBindHostname: string;
38
41
  redirectUri?: string;
39
42
  #callbackResolve?: (result: CallbackResult) => void;
40
43
  #callbackReject?: (error: string) => void;
@@ -49,12 +52,14 @@ export abstract class OAuthCallbackFlow {
49
52
  this.preferredPort = preferredPortOrOptions;
50
53
  this.callbackPath = callbackPath;
51
54
  this.callbackHostname = DEFAULT_HOSTNAME;
55
+ this.callbackBindHostname = DEFAULT_HOSTNAME;
52
56
  return;
53
57
  }
54
58
 
55
59
  this.preferredPort = preferredPortOrOptions.preferredPort;
56
60
  this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
57
61
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
62
+ this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
58
63
  this.redirectUri = preferredPortOrOptions.redirectUri;
59
64
  }
60
65
 
@@ -144,7 +149,7 @@ export abstract class OAuthCallbackFlow {
144
149
  */
145
150
  #createServer(port: number, expectedState: string): Bun.Server<unknown> {
146
151
  return Bun.serve({
147
- hostname: this.callbackHostname,
152
+ hostname: this.callbackBindHostname,
148
153
  port,
149
154
  reusePort: false,
150
155
  fetch: req => this.#handleCallback(req, expectedState),
@@ -60,6 +60,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
60
60
  name: "DeepSeek",
61
61
  available: true,
62
62
  },
63
+ {
64
+ id: "xai",
65
+ name: "xAI",
66
+ available: true,
67
+ },
63
68
  {
64
69
  id: "fireworks",
65
70
  name: "Fireworks",
@@ -310,6 +315,11 @@ export async function refreshOAuthToken(
310
315
  newCredentials = await refreshCursorToken(credentials.refresh);
311
316
  break;
312
317
  }
318
+ case "xai": {
319
+ const { refreshXaiToken } = await import("./xai");
320
+ newCredentials = await refreshXaiToken(credentials.refresh);
321
+ break;
322
+ }
313
323
  case "kilo":
314
324
  case "perplexity":
315
325
  case "huggingface":
@@ -48,6 +48,7 @@ export type OAuthProvider =
48
48
  | "venice"
49
49
  | "vercel-ai-gateway"
50
50
  | "vllm"
51
+ | "xai"
51
52
  | "xiaomi"
52
53
  | "zenmux"
53
54
  | "zai";
@@ -0,0 +1,210 @@
1
+ /** xAI OAuth flow (Grok account login). */
2
+ import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
3
+ import { generatePKCE } from "./pkce";
4
+ import type { OAuthController, OAuthCredentials } from "./types";
5
+
6
+ const XAI_OAUTH_ISSUER = "https://auth.x.ai";
7
+ export const XAI_OAUTH_DISCOVERY_URL = `${XAI_OAUTH_ISSUER}/.well-known/openid-configuration`;
8
+ export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
9
+ export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
10
+ const XAI_OAUTH_CALLBACK_PORT = 56121;
11
+ const XAI_OAUTH_CALLBACK_PATH = "/callback";
12
+ const XAI_OAUTH_REFRESH_SKEW_MS = 2 * 60 * 1000;
13
+ const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
14
+
15
+ interface XaiDiscovery {
16
+ authorizationEndpoint: string;
17
+ tokenEndpoint: string;
18
+ }
19
+
20
+ interface XaiDiscoveryPayload {
21
+ authorization_endpoint?: unknown;
22
+ token_endpoint?: unknown;
23
+ }
24
+
25
+ interface XaiTokenPayload {
26
+ access_token?: unknown;
27
+ refresh_token?: unknown;
28
+ expires_in?: unknown;
29
+ id_token?: unknown;
30
+ token_type?: unknown;
31
+ }
32
+
33
+ interface XaiJwtPayload {
34
+ sub?: unknown;
35
+ email?: unknown;
36
+ [key: string]: unknown;
37
+ }
38
+
39
+ function requestSignal(signal: AbortSignal | undefined): AbortSignal {
40
+ const timeoutSignal = AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS);
41
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
42
+ }
43
+
44
+ function validateXaiEndpoint(rawUrl: string): string {
45
+ const parsed = new URL(rawUrl);
46
+ const host = parsed.hostname.toLowerCase();
47
+ if (parsed.protocol !== "https:" || (host !== "x.ai" && !host.endsWith(".x.ai"))) {
48
+ throw new Error(`xAI OAuth discovery returned an unexpected endpoint: ${rawUrl}`);
49
+ }
50
+ return parsed.toString();
51
+ }
52
+
53
+ export async function discoverXaiOAuthEndpoints(signal?: AbortSignal): Promise<XaiDiscovery> {
54
+ const response = await fetch(XAI_OAUTH_DISCOVERY_URL, {
55
+ headers: { Accept: "application/json" },
56
+ signal: requestSignal(signal),
57
+ });
58
+ if (!response.ok) {
59
+ throw new Error(`xAI OAuth discovery failed: ${response.status} ${await response.text()}`);
60
+ }
61
+
62
+ const payload = (await response.json()) as XaiDiscoveryPayload;
63
+ if (typeof payload.authorization_endpoint !== "string" || typeof payload.token_endpoint !== "string") {
64
+ throw new Error("xAI OAuth discovery response missing authorization/token endpoints");
65
+ }
66
+
67
+ return {
68
+ authorizationEndpoint: validateXaiEndpoint(payload.authorization_endpoint),
69
+ tokenEndpoint: validateXaiEndpoint(payload.token_endpoint),
70
+ };
71
+ }
72
+
73
+ function decodeJwtPayload(token: string): XaiJwtPayload | undefined {
74
+ const parts = token.split(".");
75
+ const payload = parts[1];
76
+ if (parts.length !== 3 || !payload) return undefined;
77
+ try {
78
+ return JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as XaiJwtPayload;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ function getTokenIdentity(accessToken: string, idToken: string | undefined): { accountId?: string; email?: string } {
85
+ const payload = (idToken ? decodeJwtPayload(idToken) : undefined) ?? decodeJwtPayload(accessToken);
86
+ const accountId = typeof payload?.sub === "string" && payload.sub.length > 0 ? payload.sub : undefined;
87
+ const email =
88
+ typeof payload?.email === "string" && payload.email.length > 0 ? payload.email.toLowerCase() : undefined;
89
+ return { accountId, email };
90
+ }
91
+
92
+ async function postXaiToken(
93
+ tokenEndpoint: string,
94
+ body: Record<string, string>,
95
+ signal?: AbortSignal,
96
+ ): Promise<XaiTokenPayload> {
97
+ const response = await fetch(tokenEndpoint, {
98
+ method: "POST",
99
+ headers: {
100
+ Accept: "application/json",
101
+ "Content-Type": "application/x-www-form-urlencoded",
102
+ },
103
+ body: new URLSearchParams(body).toString(),
104
+ signal: requestSignal(signal),
105
+ });
106
+ if (!response.ok) {
107
+ throw new Error(`xAI token request failed: ${response.status} ${await response.text()}`);
108
+ }
109
+ return (await response.json()) as XaiTokenPayload;
110
+ }
111
+
112
+ function credentialsFromTokenPayload(payload: XaiTokenPayload, refreshFallback = ""): OAuthCredentials {
113
+ if (typeof payload.access_token !== "string" || payload.access_token.length === 0) {
114
+ throw new Error("xAI token response did not include an access token");
115
+ }
116
+ const refresh =
117
+ typeof payload.refresh_token === "string" && payload.refresh_token.length > 0
118
+ ? payload.refresh_token
119
+ : refreshFallback;
120
+ if (!refresh) {
121
+ throw new Error("xAI token response did not include a refresh token");
122
+ }
123
+ const expiresIn =
124
+ typeof payload.expires_in === "number" && Number.isFinite(payload.expires_in) ? payload.expires_in : 3600;
125
+ const idToken = typeof payload.id_token === "string" ? payload.id_token : undefined;
126
+ const { accountId, email } = getTokenIdentity(payload.access_token, idToken);
127
+ return {
128
+ refresh,
129
+ access: payload.access_token,
130
+ expires: Date.now() + expiresIn * 1000 - XAI_OAUTH_REFRESH_SKEW_MS,
131
+ accountId,
132
+ email,
133
+ };
134
+ }
135
+
136
+ export class XaiOAuthFlow extends OAuthCallbackFlow {
137
+ #verifier = "";
138
+ #discovery: XaiDiscovery | undefined;
139
+
140
+ constructor(ctrl: OAuthController) {
141
+ super(ctrl, {
142
+ preferredPort: XAI_OAUTH_CALLBACK_PORT,
143
+ callbackPath: XAI_OAUTH_CALLBACK_PATH,
144
+ callbackHostname: "127.0.0.1",
145
+ callbackBindHostname: "127.0.0.1",
146
+ redirectUri: `http://127.0.0.1:${XAI_OAUTH_CALLBACK_PORT}${XAI_OAUTH_CALLBACK_PATH}`,
147
+ } satisfies OAuthCallbackFlowOptions);
148
+ }
149
+
150
+ async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
151
+ const pkce = await generatePKCE();
152
+ this.#verifier = pkce.verifier;
153
+ this.#discovery = await discoverXaiOAuthEndpoints(this.ctrl.signal);
154
+ const params = new URLSearchParams({
155
+ response_type: "code",
156
+ client_id: XAI_OAUTH_CLIENT_ID,
157
+ redirect_uri: redirectUri,
158
+ scope: XAI_OAUTH_SCOPE,
159
+ code_challenge: pkce.challenge,
160
+ code_challenge_method: "S256",
161
+ state,
162
+ nonce: crypto.randomUUID(),
163
+ });
164
+ return {
165
+ url: `${this.#discovery.authorizationEndpoint}?${params.toString()}`,
166
+ instructions:
167
+ "Complete xAI/Grok login in your browser. If the browser cannot reach this machine, paste the final redirect URL or authorization code when prompted.",
168
+ };
169
+ }
170
+
171
+ async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
172
+ if (!this.#verifier) {
173
+ throw new Error("xAI OAuth PKCE verifier was not initialized");
174
+ }
175
+ const discovery = this.#discovery ?? (await discoverXaiOAuthEndpoints(this.ctrl.signal));
176
+ const tokenPayload = await postXaiToken(
177
+ discovery.tokenEndpoint,
178
+ {
179
+ grant_type: "authorization_code",
180
+ client_id: XAI_OAUTH_CLIENT_ID,
181
+ code,
182
+ redirect_uri: redirectUri,
183
+ code_verifier: this.#verifier,
184
+ },
185
+ this.ctrl.signal,
186
+ );
187
+ return credentialsFromTokenPayload(tokenPayload);
188
+ }
189
+ }
190
+
191
+ export async function loginXai(ctrl: OAuthController): Promise<OAuthCredentials> {
192
+ return new XaiOAuthFlow(ctrl).login();
193
+ }
194
+
195
+ export async function refreshXaiToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> {
196
+ if (!refreshToken) {
197
+ throw new Error("xAI credentials are expired and do not include a refresh token");
198
+ }
199
+ const discovery = await discoverXaiOAuthEndpoints(signal);
200
+ const tokenPayload = await postXaiToken(
201
+ discovery.tokenEndpoint,
202
+ {
203
+ grant_type: "refresh_token",
204
+ client_id: XAI_OAUTH_CLIENT_ID,
205
+ refresh_token: refreshToken,
206
+ },
207
+ signal,
208
+ );
209
+ return credentialsFromTokenPayload(tokenPayload, refreshToken);
210
+ }