mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 20:23:23 +00:00
tweak(opencode): make xAI OAuth device-only to reduce confusion w/ headless environments (#40537)
This commit is contained in:
parent
66fdd51f0d
commit
cb88db6ce3
3 changed files with 15 additions and 345 deletions
|
|
@ -1,14 +1,9 @@
|
|||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { OAUTH_DUMMY_KEY } from "../auth"
|
||||
import { createServer } from "http"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
|
||||
|
||||
// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from
|
||||
// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships
|
||||
// for desktop OAuth flows. Source of truth: hermes-agent PR #26534.
|
||||
// Public Grok-CLI OAuth client.
|
||||
const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
|
||||
const AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize"
|
||||
const TOKEN_URL = "https://auth.x.ai/oauth2/token"
|
||||
// RFC 8628 device authorization grant. Confirmed exposed by xAI's
|
||||
// /.well-known/openid-configuration as `device_authorization_endpoint`
|
||||
|
|
@ -30,51 +25,15 @@ const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000
|
|||
const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000
|
||||
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000
|
||||
|
||||
// xAI rejects redirect_uris that don't match what was registered for the
|
||||
// Grok-CLI client. The host:port pair is part of the registration, so we have
|
||||
// to bind the loopback server to this exact port.
|
||||
const OAUTH_HOST = "127.0.0.1"
|
||||
const OAUTH_PORT = 56121
|
||||
const OAUTH_REDIRECT_PATH = "/callback"
|
||||
const REDIRECT_URI = `http://${OAUTH_HOST}:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
|
||||
|
||||
// Refresh the access token a little before it actually expires so a single
|
||||
// long-running tool call doesn't have to recover from a mid-flight 401.
|
||||
const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000
|
||||
|
||||
interface XaiAuthPluginOptions {
|
||||
authorizeUrl?: string
|
||||
tokenUrl?: string
|
||||
deviceAuthorizationUrl?: string
|
||||
}
|
||||
|
||||
interface PkceCodes {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<PkceCodes> {
|
||||
const verifier = generateRandomString(64)
|
||||
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))
|
||||
return { verifier, challenge: base64UrlEncode(hash) }
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
return Array.from(crypto.getRandomValues(new Uint8Array(length)))
|
||||
.map((b) => chars[b % chars.length])
|
||||
.join("")
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const binary = String.fromCharCode(...new Uint8Array(buffer))
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
|
|
@ -115,55 +74,6 @@ export function accessTokenIsExpiring(
|
|||
}
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl(
|
||||
pkce: PkceCodes,
|
||||
state: string,
|
||||
nonce: string,
|
||||
options: XaiAuthPluginOptions = {},
|
||||
): string {
|
||||
// `plan=generic` opts the consent screen into xAI's generic OAuth plan tier;
|
||||
// without it, accounts.x.ai rejects loopback OAuth from non-allowlisted
|
||||
// clients. `referrer=opencode` lets xAI attribute opencode-originated
|
||||
// logins in their OAuth server logs (best-effort attribution while we
|
||||
// continue to reuse the Grok-CLI client_id).
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
scope: SCOPE,
|
||||
code_challenge: pkce.challenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
nonce,
|
||||
plan: "generic",
|
||||
referrer: "opencode",
|
||||
})
|
||||
return `${options.authorizeUrl ?? AUTHORIZE_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
pkce: PkceCodes,
|
||||
options: XaiAuthPluginOptions = {},
|
||||
): Promise<TokenResponse> {
|
||||
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: authHeaders(),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
client_id: CLIENT_ID,
|
||||
code_verifier: pkce.verifier,
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "")
|
||||
throw new Error(`xAI token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`)
|
||||
}
|
||||
return response.json() as Promise<TokenResponse>
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise<TokenResponse> {
|
||||
const response = await fetch(options.tokenUrl ?? TOKEN_URL, {
|
||||
method: "POST",
|
||||
|
|
@ -202,6 +112,7 @@ export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Pro
|
|||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
scope: SCOPE,
|
||||
referrer: "opencode",
|
||||
}).toString(),
|
||||
})
|
||||
if (!response.ok) {
|
||||
|
|
@ -285,170 +196,6 @@ export async function pollDeviceCodeToken(
|
|||
throw new Error("xAI device authorization timed out")
|
||||
}
|
||||
|
||||
// CORS allowlist for the loopback callback. The redirect_uri itself is
|
||||
// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept
|
||||
// xAI's own auth origins for additional defense-in-depth on the OPTIONS
|
||||
// preflight.
|
||||
const CORS_ALLOWED_ORIGINS = new Set(["https://accounts.x.ai", "https://auth.x.ai"])
|
||||
|
||||
interface PendingOAuth {
|
||||
pkce: PkceCodes
|
||||
state: string
|
||||
resolve: (tokens: TokenResponse) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> {
|
||||
if (oauthServer) return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const reqUrl = req.url || "/"
|
||||
const url = new URL(reqUrl, `http://${OAUTH_HOST}:${OAUTH_PORT}`)
|
||||
|
||||
const origin = req.headers["origin"]
|
||||
const allowOrigin = typeof origin === "string" && CORS_ALLOWED_ORIGINS.has(origin) ? origin : ""
|
||||
if (allowOrigin) {
|
||||
res.setHeader("Access-Control-Allow-Origin", allowOrigin)
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
res.setHeader("Access-Control-Allow-Private-Network", "true")
|
||||
res.setHeader("Vary", "Origin")
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === OAUTH_REDIRECT_PATH) {
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
const errorDescription = url.searchParams.get("error_description")
|
||||
|
||||
if (error) {
|
||||
const errorMsg = errorDescription || error
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
const errorMsg = "Missing authorization code"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
if (!pendingOAuth || state !== pendingOAuth.state) {
|
||||
const errorMsg = "Invalid state - potential CSRF attack"
|
||||
pendingOAuth?.reject(new Error(errorMsg))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
const current = pendingOAuth
|
||||
pendingOAuth = undefined
|
||||
|
||||
exchangeCodeForTokens(code, current.pkce)
|
||||
.then((tokens) => current.resolve(tokens))
|
||||
.catch((err) => current.reject(err))
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(OauthCallbackPage.success({ provider: "xAI" }))
|
||||
return
|
||||
}
|
||||
|
||||
if (url.pathname === "/cancel") {
|
||||
pendingOAuth?.reject(new Error("Login cancelled"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200)
|
||||
res.end("Login cancelled")
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
// listen() failures (e.g. EADDRINUSE because Grok-CLI is bound to the same
|
||||
// pinned port) must clear `oauthServer` and remove our error listener,
|
||||
// otherwise the next startOAuthServer() short-circuits on the truthy check
|
||||
// and returns a redirect_uri pointing at nothing.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
oauthServer = undefined
|
||||
reject(err)
|
||||
}
|
||||
server.once("error", onError)
|
||||
server.listen(OAUTH_PORT, OAUTH_HOST, () => {
|
||||
server.removeListener("error", onError)
|
||||
// After listen() succeeds, install a permanent log-only listener so
|
||||
// that subsequent server errors (e.g. accept() failures, socket-level
|
||||
// errors) don't trip Node's default "unhandled error event = throw"
|
||||
// behavior and crash the entire opencode process. Matches the silent-
|
||||
// swallow behavior the Codex plugin gets from its permanent
|
||||
// `oauthServer!.on("error", reject)`.
|
||||
resolve()
|
||||
})
|
||||
oauthServer = server
|
||||
})
|
||||
|
||||
return { port: OAUTH_PORT, redirectUri: REDIRECT_URI }
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (oauthServer) {
|
||||
oauthServer.close()
|
||||
oauthServer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResponse> {
|
||||
// A previous in-flight authorize() that the user abandoned (or that is
|
||||
// being superseded by a fresh attempt) still owns `pendingOAuth`. Reject
|
||||
// it eagerly so its caller stops waiting on a state value that can never
|
||||
// match the next callback.
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth.reject(new Error("Superseded by a newer xAI authorize request"))
|
||||
pendingOAuth = undefined
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth = undefined
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
|
||||
pendingOAuth = {
|
||||
pkce,
|
||||
state,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface RefreshResult {
|
||||
access: string
|
||||
refresh: string
|
||||
|
|
@ -548,40 +295,6 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
|
|||
}
|
||||
},
|
||||
methods: [
|
||||
{
|
||||
label: "xAI Grok OAuth (SuperGrok Subscription)",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
await startOAuthServer()
|
||||
const pkce = await generatePKCE()
|
||||
const state = generateState()
|
||||
const nonce = generateState()
|
||||
const authUrl = buildAuthorizeUrl(pkce, state, nonce, options)
|
||||
|
||||
const callbackPromise = waitForOAuthCallback(pkce, state)
|
||||
|
||||
return {
|
||||
url: authUrl,
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
method: "auto" as const,
|
||||
callback: async () => {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
return {
|
||||
type: "success" as const,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
}
|
||||
} catch (err) {
|
||||
return { type: "failed" as const }
|
||||
} finally {
|
||||
stopOAuthServer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
// RFC 8628 device-code flow. The CLI prints a verification URL
|
||||
// and a short user_code that the user enters in a browser on
|
||||
|
|
@ -591,7 +304,7 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp
|
|||
// user's browser. Defends the only attack surface (the polling
|
||||
// loop) with the standard authorization_pending / slow_down
|
||||
// backoff and a hard deadline from xAI's `expires_in`.
|
||||
label: "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
label: "SuperGrok Subscription",
|
||||
type: "oauth",
|
||||
authorize: async () => {
|
||||
const device = await requestDeviceCode(options)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
accessTokenIsExpiring,
|
||||
buildAuthorizeUrl,
|
||||
pollDeviceCodeToken,
|
||||
requestDeviceCode,
|
||||
XaiAuthPlugin,
|
||||
|
|
@ -76,32 +75,6 @@ describe("plugin.xai", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("buildAuthorizeUrl", () => {
|
||||
const pkce = { verifier: "ver", challenge: "chal" }
|
||||
|
||||
test("includes required OAuth + PKCE + OIDC params", () => {
|
||||
const url = new URL(buildAuthorizeUrl(pkce, "state-abc", "nonce-xyz"))
|
||||
const params = url.searchParams
|
||||
|
||||
expect(url.origin + url.pathname).toBe("https://auth.x.ai/oauth2/authorize")
|
||||
expect(params.get("response_type")).toBe("code")
|
||||
expect(params.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828")
|
||||
expect(params.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback")
|
||||
expect(params.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access")
|
||||
expect(params.get("code_challenge")).toBe("chal")
|
||||
expect(params.get("code_challenge_method")).toBe("S256")
|
||||
expect(params.get("state")).toBe("state-abc")
|
||||
expect(params.get("nonce")).toBe("nonce-xyz")
|
||||
expect(params.get("plan")).toBe("generic")
|
||||
expect(params.get("referrer")).toBe("opencode")
|
||||
})
|
||||
|
||||
test("supports endpoint override for local integration tests", () => {
|
||||
const url = new URL(buildAuthorizeUrl(pkce, "s", "n", { authorizeUrl: "http://127.0.0.1/oauth2/authorize" }))
|
||||
expect(url.origin + url.pathname).toBe("http://127.0.0.1/oauth2/authorize")
|
||||
})
|
||||
})
|
||||
|
||||
describe("loader", () => {
|
||||
test("returns no options unless stored auth is OAuth and exposes methods in order", async () => {
|
||||
const hooks = await XaiAuthPlugin({} as any)
|
||||
|
|
@ -110,8 +83,7 @@ describe("plugin.xai", () => {
|
|||
await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any),
|
||||
).toEqual({})
|
||||
expect(hooks.auth!.methods.map((m) => [m.type, m.label])).toEqual([
|
||||
["oauth", "xAI Grok OAuth (SuperGrok Subscription)"],
|
||||
["oauth", "xAI Grok OAuth (Headless / Remote / VPS)"],
|
||||
["oauth", "SuperGrok Subscription"],
|
||||
["api", "Manually enter API Key"],
|
||||
])
|
||||
})
|
||||
|
|
@ -426,7 +398,7 @@ describe("plugin.xai", () => {
|
|||
const hooks = await XaiAuthPlugin({} as any, serverOptions(server))
|
||||
const headless = hooks.auth!.methods.find(
|
||||
(m): m is Extract<typeof m, { type: "oauth" }> =>
|
||||
m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
m.type === "oauth" && m.label === "SuperGrok Subscription",
|
||||
)!
|
||||
const result = await headless.authorize!()
|
||||
|
||||
|
|
@ -450,7 +422,7 @@ describe("plugin.xai", () => {
|
|||
})
|
||||
const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
|
||||
(m): m is Extract<typeof m, { type: "oauth" }> =>
|
||||
m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
m.type === "oauth" && m.label === "SuperGrok Subscription",
|
||||
)!
|
||||
expect((await headless.authorize!()).url).toBe("https://x.ai/device")
|
||||
})
|
||||
|
|
@ -474,6 +446,7 @@ describe("plugin.xai", () => {
|
|||
expect(parsed.get("scope")).toContain("offline_access")
|
||||
expect(parsed.get("scope")).toContain("grok-cli:access")
|
||||
expect(parsed.get("scope")).toContain("api:access")
|
||||
expect(parsed.get("referrer")).toBe("opencode")
|
||||
await expect(
|
||||
requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() }),
|
||||
).rejects.toThrow(/429.*rate limited/)
|
||||
|
|
@ -612,7 +585,7 @@ describe("plugin.xai", () => {
|
|||
})
|
||||
const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find(
|
||||
(m): m is Extract<typeof m, { type: "oauth" }> =>
|
||||
m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)",
|
||||
m.type === "oauth" && m.label === "SuperGrok Subscription",
|
||||
)!
|
||||
expect(await ((await headless.authorize!()) as any).callback()).toEqual({ type: "failed" })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2308,9 +2308,9 @@ Some useful routing options:
|
|||
|
||||
### xAI
|
||||
|
||||
Three ways to authenticate: a SuperGrok subscription via browser OAuth, the same SuperGrok subscription via a headless device-code flow (for VPS / SSH / Docker), or a pay-as-you-go API key from the xAI console.
|
||||
Two ways to authenticate: a SuperGrok subscription via device-code OAuth or a pay-as-you-go API key from the xAI console.
|
||||
|
||||
#### Option A — SuperGrok OAuth (browser login)
|
||||
#### Option A — SuperGrok subscription
|
||||
|
||||
1. Run the `/connect` command and search for **xAI**.
|
||||
|
||||
|
|
@ -2318,9 +2318,11 @@ Three ways to authenticate: a SuperGrok subscription via browser OAuth, the same
|
|||
/connect
|
||||
```
|
||||
|
||||
2. Select **xAI Grok OAuth (SuperGrok Subscription)**. OpenCode opens xAI's consent screen in your browser and waits for the callback on `http://127.0.0.1:56121/callback`.
|
||||
2. Select **SuperGrok Subscription**. OpenCode opens xAI's verification link with the user code pre-populated when supported.
|
||||
|
||||
3. Run the `/models` command to select a Grok model.
|
||||
3. Approve the consent screen. If xAI asks for a code, enter the user code displayed by OpenCode. OpenCode polls xAI's token endpoint and stores the resulting OAuth tokens once you approve.
|
||||
|
||||
4. Run the `/models` command to select a Grok model.
|
||||
|
||||
```txt
|
||||
/models
|
||||
|
|
@ -2328,25 +2330,7 @@ Three ways to authenticate: a SuperGrok subscription via browser OAuth, the same
|
|||
|
||||
OpenCode refreshes the OAuth access token automatically. Any Grok or X Premium plan that includes Grok API access works; you do not need a separate `XAI_API_KEY`.
|
||||
|
||||
#### Option B — SuperGrok device-code (headless / remote server / VPS)
|
||||
|
||||
Use this when OpenCode is running somewhere a browser can't reach the loopback redirect: a VPS, a remote dev box over SSH, inside Docker, in CI, etc. No callback port is opened on the host running OpenCode — instead xAI hands the CLI a short code that you type into a browser on any other device (laptop, phone, …).
|
||||
|
||||
1. Run the `/connect` command on the remote host and search for **xAI**.
|
||||
|
||||
```txt
|
||||
/connect
|
||||
```
|
||||
|
||||
2. Select **xAI Grok OAuth (Headless / Remote / VPS)**. OpenCode prints a verification URL and a short user code.
|
||||
|
||||
```txt
|
||||
Open https://x.ai/device on any device and enter code: ABCD-1234
|
||||
```
|
||||
|
||||
3. Open the URL on a device that has a browser (your laptop or phone), enter the code, and approve the consent screen. OpenCode polls xAI's token endpoint and stores the resulting OAuth tokens once you approve. Token refresh works the same as Option A.
|
||||
|
||||
#### Option C — API key
|
||||
#### Option B — API key
|
||||
|
||||
1. Head over to the [xAI console](https://console.x.ai/), create an account, and generate an API key.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue