@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 +6 -0
- package/README.md +4 -2
- package/dist/types/utils/oauth/callback-server.d.ts +3 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/dist/types/utils/oauth/xai.d.ts +23 -0
- package/package.json +2 -2
- package/src/auth-broker/remote-store.ts +1 -1
- package/src/auth-storage.ts +17 -2
- package/src/cli.ts +1 -0
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/openai-completions.ts +3 -8
- package/src/providers/openai-responses-server.ts +6 -6
- package/src/utils/oauth/callback-server.ts +6 -1
- package/src/utils/oauth/index.ts +10 -0
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/oauth/xai.ts +210 -0
package/CHANGELOG.md
CHANGED
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.
|
|
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.
|
|
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 (
|
|
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);
|
package/src/auth-storage.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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.
|
|
152
|
+
hostname: this.callbackBindHostname,
|
|
148
153
|
port,
|
|
149
154
|
reusePort: false,
|
|
150
155
|
fetch: req => this.#handleCallback(req, expectedState),
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -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":
|
package/src/utils/oauth/types.ts
CHANGED
|
@@ -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
|
+
}
|