diff --git a/docs/providers/xai.md b/docs/providers/xai.md index 5dc11b46a18..6a1a9952b6e 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -12,11 +12,12 @@ OpenClaw ships a bundled `xai` provider plugin for Grok models. - Use either an API key from the [xAI console](https://console.x.ai/) or - xAI OAuth browser sign-in with an eligible xAI account. OAuth does not - require an xAI API key, and OpenClaw does not require the Grok Build app. - xAI may still label the consent app as Grok Build because OpenClaw uses - xAI's shared OAuth client. + Use either an API key from the [xAI console](https://console.x.ai/), + xAI OAuth browser sign-in with an eligible xAI account, or xAI device-code + sign-in for remote/VPS hosts where a localhost browser callback is awkward. + OAuth does not require an xAI API key, and OpenClaw does not require the + Grok Build app. xAI may still label the consent app as Grok Build because + OpenClaw uses xAI's shared OAuth client. Set `XAI_API_KEY`, run the API-key wizard, or start the OAuth flow: @@ -24,7 +25,9 @@ OpenClaw ships a bundled `xai` provider plugin for Grok models. ```bash openclaw onboard --auth-choice xai-api-key openclaw onboard --auth-choice xai-oauth + openclaw onboard --auth-choice xai-device-code openclaw models auth login --provider xai --method oauth + openclaw models auth login --provider xai --device-code ``` @@ -40,7 +43,8 @@ OpenClaw ships a bundled `xai` provider plugin for Grok models. OpenClaw uses the xAI Responses API as the bundled xAI transport. The same credential from `openclaw onboard --auth-choice xai-api-key` or -`openclaw onboard --auth-choice xai-oauth` can also power first-class +`openclaw onboard --auth-choice xai-oauth` / +`openclaw onboard --auth-choice xai-device-code` can also power first-class `x_search`, remote `code_execution`, and xAI image/video generation. Speech and transcription currently require `XAI_API_KEY` or provider config. `XAI_API_KEY` or plugin web-search config can power Grok-backed `web_search` too. @@ -51,6 +55,12 @@ and, by default, `x_search` through an operator xAI Responses proxy. `code_execution` tuning lives under `plugins.entries.xai.config.codeExecution`. + +Use `xai-device-code` when signing in from SSH, Docker, or a VPS. OpenClaw +prints an xAI URL and short code; finish sign-in in any local browser while the +remote process polls xAI for the completed token exchange. + + ## Built-in catalog OpenClaw includes the current xAI chat models out of the box, ordered newest diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index adb21f3a3a5..ba027d80efd 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -60,6 +60,15 @@ function requireEntry(entries: T[], id: string): T { } describe("xai provider plugin", () => { + it("exposes OAuth and device-code auth choices", async () => { + const provider = await registerSingleProviderPlugin(plugin); + + expect(provider.auth?.map((method) => method.id)).toEqual(["api-key", "oauth", "device-code"]); + const deviceCode = provider.auth?.find((method) => method.id === "device-code"); + expect(deviceCode?.kind).toBe("device_code"); + expect(deviceCode?.wizard?.choiceId).toBe("xai-device-code"); + }); + it("registers xAI speech providers for batch and streaming STT", async () => { const { mediaProviders, realtimeTranscriptionProviders } = await registerProviderPlugin({ plugin, diff --git a/extensions/xai/index.ts b/extensions/xai/index.ts index 007ee5335ee..aa21ccc864a 100644 --- a/extensions/xai/index.ts +++ b/extensions/xai/index.ts @@ -31,7 +31,11 @@ import { buildMissingXSearchApiKeyPayload, createXSearchToolDefinition, } from "./x-search-tool-shared.js"; -import { createXaiOAuthAuthMethod, refreshXaiOAuthCredential } from "./xai-oauth.js"; +import { + createXaiDeviceCodeAuthMethod, + createXaiOAuthAuthMethod, + refreshXaiOAuthCredential, +} from "./xai-oauth.js"; const PROVIDER_ID = "xai"; type CodeExecutionModule = typeof import("./code-execution.js"); @@ -183,7 +187,7 @@ export default defineSingleProviderPluginEntry({ }, }, ], - extraAuth: [createXaiOAuthAuthMethod()], + extraAuth: [createXaiOAuthAuthMethod(), createXaiDeviceCodeAuthMethod()], catalog: { buildProvider: buildXaiProvider, }, diff --git a/extensions/xai/openclaw.plugin.json b/extensions/xai/openclaw.plugin.json index 7a61208130a..a51b4bad36c 100644 --- a/extensions/xai/openclaw.plugin.json +++ b/extensions/xai/openclaw.plugin.json @@ -62,6 +62,17 @@ "groupLabel": "xAI (Grok)", "groupHint": "API key or browser OAuth", "onboardingFeatured": true + }, + { + "provider": "xai", + "method": "device-code", + "choiceId": "xai-device-code", + "choiceLabel": "xAI device code", + "choiceHint": "Remote-friendly browser sign-in without a localhost callback", + "groupId": "xai", + "groupLabel": "xAI (Grok)", + "groupHint": "API key or browser OAuth", + "onboardingFeatured": true } ], "uiHints": { diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index d0680502f36..22159b6db8f 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -1,9 +1,10 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildXaiOAuthAuthorizationCodeTokenBody, buildXaiOAuthAuthorizeUrl, fetchXaiOAuthDiscovery, isTrustedXaiOAuthEndpoint, + loginXaiDeviceCode, refreshXaiOAuthCredential, XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST, XAI_OAUTH_CALLBACK_PORT, @@ -20,7 +21,26 @@ function jsonResponse(value: unknown, init?: ResponseInit): Response { }); } +function createJwt(payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); + const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); + return `${header}.${body}.signature`; +} + +function requireStringBody(init: RequestInit | undefined): string { + if (typeof init?.body !== "string") { + throw new Error("expected request body to be a string"); + } + return init.body; +} + describe("xAI OAuth", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + }); + it("accepts only trusted xAI OAuth endpoints", () => { expect(isTrustedXaiOAuthEndpoint("https://auth.x.ai/oauth2/token")).toBe(true); expect(isTrustedXaiOAuthEndpoint("https://accounts.x.ai/oauth2/token")).toBe(true); @@ -80,12 +100,14 @@ describe("xAI OAuth", () => { const fetchImpl = vi.fn(async () => jsonResponse({ authorization_endpoint: "https://auth.x.ai/oauth2/authorize", + device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code", token_endpoint: "https://auth.x.ai/oauth2/token", }), ) as unknown as typeof fetch; await expect(fetchXaiOAuthDiscovery({ fetchImpl })).resolves.toEqual({ authorizationEndpoint: "https://auth.x.ai/oauth2/authorize", + deviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code", tokenEndpoint: "https://auth.x.ai/oauth2/token", }); @@ -99,6 +121,7 @@ describe("xAI OAuth", () => { const poisonedFetch = vi.fn(async () => jsonResponse({ authorization_endpoint: "https://auth.x.ai/oauth2/authorize", + device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code", token_endpoint: "https://evil.test/oauth2/token", }), ) as unknown as typeof fetch; @@ -143,4 +166,98 @@ describe("xAI OAuth", () => { expect(refreshed.expires).toBe(121_000); vi.unstubAllEnvs(); }); + + it("logs in with xAI device code without a localhost callback", async () => { + vi.stubEnv("OPENCLAW_VERSION", "2026.3.22"); + const progress = { + update: vi.fn(), + stop: vi.fn(), + }; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + authorization_endpoint: "https://auth.x.ai/oauth2/authorize", + device_authorization_endpoint: "https://auth.x.ai/oauth2/device/code", + token_endpoint: "https://auth.x.ai/oauth2/token", + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + device_code: "device-code-1", + user_code: "ABCD-1234", + verification_uri: "https://accounts.x.ai/oauth2/device", + verification_uri_complete: "https://accounts.x.ai/oauth2/device?user_code=ABCD-1234", + expires_in: 900, + interval: 5, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + access_token: createJwt({ exp: 4, sub: "acct-1" }), + refresh_token: "refresh-1", + id_token: createJwt({ + sub: "acct-1", + email: "dev@example.com", + name: "Dev User", + }), + expires_in: 120, + }), + ); + vi.stubGlobal("fetch", fetchImpl); + const ctx = { + config: {}, + isRemote: true, + openUrl: vi.fn(async () => {}), + prompter: { + progress: vi.fn(() => progress), + note: vi.fn(async () => {}), + }, + runtime: { + log: vi.fn(), + }, + oauth: {}, + }; + + const result = await loginXaiDeviceCode(ctx as never); + + expect(ctx.openUrl).not.toHaveBeenCalled(); + expect(ctx.prompter.note).toHaveBeenCalledWith( + expect.stringContaining("ABCD-1234"), + "xAI device code", + ); + const remoteLog = ctx.runtime.log.mock.calls[0]?.[0]; + expect(remoteLog).toContain("https://accounts.x.ai/oauth2/device"); + expect(remoteLog).not.toContain("ABCD-1234"); + const deviceRequest = fetchImpl.mock.calls[1]?.[1]; + expect(deviceRequest?.method).toBe("POST"); + const deviceBody = requireStringBody(deviceRequest); + expect(deviceBody).toContain(`client_id=${encodeURIComponent(XAI_OAUTH_CLIENT_ID)}`); + expect(deviceBody).toContain(`scope=${encodeURIComponent(XAI_OAUTH_SCOPE)}`); + + const tokenRequest = fetchImpl.mock.calls[2]?.[1]; + expect(tokenRequest?.method).toBe("POST"); + const tokenBody = requireStringBody(tokenRequest); + expect(tokenBody).toContain( + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code", + ); + expect(tokenBody).toContain("device_code=device-code-1"); + + const credential = result.profiles[0]?.credential as Record | undefined; + expect(credential).toMatchObject({ + type: "oauth", + provider: "xai", + refresh: "refresh-1", + email: "dev@example.com", + displayName: "Dev User", + tokenEndpoint: "https://auth.x.ai/oauth2/token", + deviceAuthorizationEndpoint: "https://auth.x.ai/oauth2/device/code", + issuer: "https://auth.x.ai", + authFlow: "device-code", + accountId: "acct-1", + }); + expect(credential?.access).toEqual(expect.any(String)); + expect(progress.update).toHaveBeenCalledWith("Waiting for xAI device authorization..."); + expect(progress.stop).toHaveBeenCalledWith("xAI device code complete"); + }); }); diff --git a/extensions/xai/xai-oauth.ts b/extensions/xai/xai-oauth.ts index 95f5e592973..b43d3ffce19 100644 --- a/extensions/xai/xai-oauth.ts +++ b/extensions/xai/xai-oauth.ts @@ -15,6 +15,8 @@ import { xaiUserAgent } from "./src/xai-user-agent.js"; const PROVIDER_ID = "xai"; export const XAI_OAUTH_METHOD_ID = "oauth"; export const XAI_OAUTH_CHOICE_ID = "xai-oauth"; +export const XAI_DEVICE_CODE_METHOD_ID = "device-code"; +export const XAI_DEVICE_CODE_CHOICE_ID = "xai-device-code"; export const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; export const XAI_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access"; export const XAI_OAUTH_ISSUER = "https://auth.x.ai"; @@ -29,9 +31,14 @@ export const XAI_OAUTH_CALLBACK_CORS_ORIGIN_ALLOWLIST = ["auth.x.ai", "accounts. const XAI_OAUTH_TIMEOUT_MS = 5 * 60 * 1000; const XAI_OAUTH_FETCH_TIMEOUT_MS = 30 * 1000; +const XAI_DEVICE_CODE_DEFAULT_INTERVAL_MS = 5 * 1000; +const XAI_DEVICE_CODE_MIN_INTERVAL_MS = 1 * 1000; +const XAI_DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5 * 1000; +const XAI_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; type XaiOAuthDiscovery = { authorizationEndpoint: string; + deviceAuthorizationEndpoint: string; tokenEndpoint: string; }; @@ -53,6 +60,20 @@ type XaiOAuthFetchOptions = { now?: () => number; }; +type XaiDeviceCodeResponse = { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresInMs: number; + intervalMs: number; +}; + +type XaiOAuthErrorResponse = { + error?: string; + errorDescription?: string; +}; + function getFetchImpl(fetchImpl?: typeof fetch): typeof fetch { return fetchImpl ?? fetch; } @@ -110,8 +131,13 @@ export async function fetchXaiOAuthDiscovery( }); const json = readStringRecord(await readJsonResponse(response, "xAI OAuth discovery")); const authorizationEndpoint = json.authorization_endpoint; + const deviceAuthorizationEndpoint = json.device_authorization_endpoint; const tokenEndpoint = json.token_endpoint; - if (typeof authorizationEndpoint !== "string" || typeof tokenEndpoint !== "string") { + if ( + typeof authorizationEndpoint !== "string" || + typeof deviceAuthorizationEndpoint !== "string" || + typeof tokenEndpoint !== "string" + ) { throw new Error("xAI OAuth discovery response is missing endpoints"); } return { @@ -119,6 +145,10 @@ export async function fetchXaiOAuthDiscovery( authorizationEndpoint, "authorization endpoint", ), + deviceAuthorizationEndpoint: requireTrustedXaiOAuthEndpoint( + deviceAuthorizationEndpoint, + "device authorization endpoint", + ), tokenEndpoint: requireTrustedXaiOAuthEndpoint(tokenEndpoint, "token endpoint"), }; } @@ -175,6 +205,19 @@ function normalizeExpires(value: unknown, now: () => number): number | undefined return now() + seconds * 1000; } +function normalizePositiveSecondsToMs(value: unknown): number | undefined { + const seconds = + typeof value === "number" + ? value + : typeof value === "string" + ? Number.parseFloat(value) + : Number.NaN; + if (!Number.isFinite(seconds) || seconds <= 0) { + return undefined; + } + return Math.trunc(seconds * 1000); +} + function parseXaiOAuthTokenResponse( value: unknown, now: () => number, @@ -222,6 +265,28 @@ function deriveExpiresFromJwt(token: string | undefined): number | undefined { return exp * 1000; } +function parseXaiOAuthErrorResponse(value: unknown): XaiOAuthErrorResponse { + const json = readStringRecord(value); + const error = typeof json.error === "string" ? json.error : undefined; + const errorDescription = + typeof json.error_description === "string" ? json.error_description : undefined; + return { + ...(error ? { error } : {}), + ...(errorDescription ? { errorDescription } : {}), + }; +} + +function formatXaiOAuthError(params: { context: string; status: number; body: unknown }): string { + const error = parseXaiOAuthErrorResponse(params.body); + if (error.error && error.errorDescription) { + return `${params.context} failed (${params.status}): ${error.error} (${error.errorDescription})`; + } + if (error.error) { + return `${params.context} failed (${params.status}): ${error.error}`; + } + return `${params.context} failed (${params.status})`; +} + async function exchangeXaiOAuthToken( params: { tokenEndpoint: string; @@ -250,6 +315,147 @@ async function exchangeXaiOAuthToken( ); } +async function requestXaiDeviceCode( + params: { + deviceAuthorizationEndpoint: string; + } & XaiOAuthFetchOptions, +): Promise { + const response = await getFetchImpl(params.fetchImpl)( + requireTrustedXaiOAuthEndpoint( + params.deviceAuthorizationEndpoint, + "device authorization endpoint", + ), + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": xaiUserAgent(), + }, + body: toFormUrlEncoded({ + client_id: XAI_OAUTH_CLIENT_ID, + scope: XAI_OAUTH_SCOPE, + }), + signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS), + }, + ); + const json = readStringRecord(await readJsonResponse(response, "xAI device code request")); + const deviceCode = json.device_code; + const userCode = json.user_code; + const verificationUri = json.verification_uri; + const verificationUriComplete = json.verification_uri_complete; + if ( + typeof deviceCode !== "string" || + deviceCode.trim().length === 0 || + typeof userCode !== "string" || + userCode.trim().length === 0 || + typeof verificationUri !== "string" || + verificationUri.trim().length === 0 + ) { + throw new Error( + "xAI device code response is missing device_code, user_code, or verification_uri", + ); + } + const trustedVerificationUri = requireTrustedXaiOAuthEndpoint( + verificationUri, + "device verification URI", + ); + const trustedVerificationUriComplete = + typeof verificationUriComplete === "string" && verificationUriComplete.trim().length > 0 + ? requireTrustedXaiOAuthEndpoint(verificationUriComplete, "complete device verification URI") + : undefined; + return { + deviceCode, + userCode, + verificationUri: trustedVerificationUri, + ...(trustedVerificationUriComplete + ? { verificationUriComplete: trustedVerificationUriComplete } + : {}), + expiresInMs: normalizePositiveSecondsToMs(json.expires_in) ?? XAI_OAUTH_TIMEOUT_MS, + intervalMs: normalizePositiveSecondsToMs(json.interval) ?? XAI_DEVICE_CODE_DEFAULT_INTERVAL_MS, + }; +} + +function resolveNextXaiDeviceCodePollDelayMs(intervalMs: number, deadlineMs: number): number { + const remainingMs = Math.max(0, deadlineMs - Date.now()); + return Math.min(Math.max(intervalMs, XAI_DEVICE_CODE_MIN_INTERVAL_MS), remainingMs); +} + +async function pollXaiDeviceCodeToken( + params: { + tokenEndpoint: string; + deviceCode: string; + expiresInMs: number; + intervalMs: number; + } & XaiOAuthFetchOptions, +): Promise { + const fetchImpl = getFetchImpl(params.fetchImpl); + const deadlineMs = Date.now() + params.expiresInMs; + let intervalMs = params.intervalMs; + + while (Date.now() < deadlineMs) { + const response = await fetchImpl( + requireTrustedXaiOAuthEndpoint(params.tokenEndpoint, "token endpoint"), + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + "User-Agent": xaiUserAgent(), + }, + body: toFormUrlEncoded({ + grant_type: XAI_DEVICE_CODE_GRANT_TYPE, + client_id: XAI_OAUTH_CLIENT_ID, + device_code: params.deviceCode, + }), + signal: AbortSignal.timeout(XAI_OAUTH_FETCH_TIMEOUT_MS), + }, + ); + let body: unknown; + try { + body = await response.json(); + } catch { + body = null; + } + if (response.ok) { + return parseXaiOAuthTokenResponse(body, params.now ?? Date.now, { + requireRefreshToken: true, + }); + } + + const error = parseXaiOAuthErrorResponse(body).error; + if (error === "authorization_pending") { + await new Promise((resolve) => + setTimeout(resolve, resolveNextXaiDeviceCodePollDelayMs(intervalMs, deadlineMs)), + ); + continue; + } + if (error === "slow_down") { + intervalMs += XAI_DEVICE_CODE_SLOW_DOWN_INCREMENT_MS; + await new Promise((resolve) => + setTimeout(resolve, resolveNextXaiDeviceCodePollDelayMs(intervalMs, deadlineMs)), + ); + continue; + } + if (error === "access_denied" || error === "authorization_denied") { + throw new Error("xAI device authorization was denied"); + } + if (error === "expired_token") { + throw new Error("xAI device code expired. Re-run the login."); + } + + throw new Error( + formatXaiOAuthError({ + context: "xAI device token exchange", + status: response.status, + body, + }), + ); + } + + throw new Error("xAI device authorization timed out"); +} + function decodeJwtPayload(token: string | undefined): Record { if (!token) { return {}; @@ -364,6 +570,84 @@ export async function loginXaiOAuth(ctx: ProviderAuthContext): Promise { + const expiresInMinutes = Math.max(1, Math.round(deviceCode.expiresInMs / 60_000)); + await ctx.prompter.note( + [ + ctx.isRemote + ? "Open this URL in your LOCAL browser and enter the code below." + : "Open this URL in your browser and enter the code below.", + `URL: ${deviceCode.verificationUriComplete ?? deviceCode.verificationUri}`, + `Code: ${deviceCode.userCode}`, + `Code expires in ${expiresInMinutes} minutes. Never share it.`, + ].join("\n"), + "xAI device code", + ); +} + +export async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise { + const progress = ctx.prompter.progress("Starting xAI device code flow..."); + try { + const discovery = await fetchXaiOAuthDiscovery(); + progress.update("Requesting xAI device code..."); + const deviceCode = await requestXaiDeviceCode({ + deviceAuthorizationEndpoint: discovery.deviceAuthorizationEndpoint, + }); + await noteXaiDeviceCode(ctx, deviceCode); + const browserUrl = deviceCode.verificationUriComplete ?? deviceCode.verificationUri; + const logUrl = deviceCode.verificationUri; + if (ctx.isRemote) { + ctx.runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${logUrl}\n`); + } else { + try { + await ctx.openUrl(browserUrl); + ctx.runtime.log(`Open: ${logUrl}`); + } catch { + ctx.runtime.log(`Open manually: ${logUrl}`); + } + } + + progress.update("Waiting for xAI device authorization..."); + const tokens = await pollXaiDeviceCodeToken({ + tokenEndpoint: discovery.tokenEndpoint, + deviceCode: deviceCode.deviceCode, + expiresInMs: deviceCode.expiresInMs, + intervalMs: deviceCode.intervalMs, + }); + const identity = resolveXaiOAuthIdentity(tokens); + progress.stop("xAI device code complete"); + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: XAI_DEFAULT_MODEL_REF, + access: tokens.accessToken, + refresh: tokens.refreshToken, + expires: tokens.expires, + email: identity.email, + displayName: identity.displayName, + profileName: identity.email ?? identity.accountId, + configPatch: applyXaiConfig(ctx.config), + credentialExtra: { + tokenEndpoint: discovery.tokenEndpoint, + deviceAuthorizationEndpoint: discovery.deviceAuthorizationEndpoint, + issuer: XAI_OAUTH_ISSUER, + authFlow: "device-code", + ...(tokens.idToken ? { idToken: tokens.idToken } : {}), + ...(identity.accountId ? { accountId: identity.accountId } : {}), + }, + notes: [ + "xAI device code login uses your xAI account entitlement without requiring a localhost callback.", + "xAI may label the consent app as Grok Build because OpenClaw uses xAI's shared OAuth client.", + ], + }); + } catch (err) { + progress.stop("xAI device code failed"); + throw new Error(`xAI device code failed: ${formatErrorMessage(err)}`, { cause: err }); + } +} + export async function refreshXaiOAuthCredential( credential: OAuthCredential, options: XaiOAuthFetchOptions = {}, @@ -420,3 +704,22 @@ export function createXaiOAuthAuthMethod(): ProviderAuthMethod { run: async (ctx) => loginXaiOAuth(ctx), }; } + +export function createXaiDeviceCodeAuthMethod(): ProviderAuthMethod { + return { + id: XAI_DEVICE_CODE_METHOD_ID, + label: "xAI device code", + hint: "Remote-friendly browser sign-in without a localhost callback", + kind: "device_code", + wizard: { + choiceId: XAI_DEVICE_CODE_CHOICE_ID, + choiceLabel: "xAI device code", + choiceHint: "Remote-friendly browser sign-in without a localhost callback", + groupId: PROVIDER_ID, + groupLabel: "xAI (Grok)", + groupHint: "API key or browser OAuth", + methodId: XAI_DEVICE_CODE_METHOD_ID, + }, + run: async (ctx) => loginXaiDeviceCode(ctx), + }; +}