From b0190a374e3a90a1b4813c73d7b355305a76c3fa Mon Sep 17 00:00:00 2001 From: Hadrian de Oliveira Date: Sun, 26 Jul 2026 12:09:40 -0300 Subject: [PATCH] feat: support xAI Grok via subscription OAuth --- .../operations/credentials-and-updates.md | 3 +- openwiki/quickstart.md | 3 +- src/agent/index.ts | 29 ++ src/agent/xai-grok-oauth.ts | 444 ++++++++++++++++++ src/constants.ts | 29 +- src/credentials.tsx | 113 ++++- src/env.ts | 6 + src/startup.ts | 14 + test/create-model.test.ts | 54 +++ test/model-resolution.test.ts | 12 + test/startup.test.ts | 39 ++ test/xai-grok-credentials.test.ts | 94 ++++ test/xai-grok-oauth.test.ts | 172 +++++++ test/xai-grok-provider.test.ts | 59 +++ 14 files changed, 1048 insertions(+), 23 deletions(-) create mode 100644 src/agent/xai-grok-oauth.ts create mode 100644 test/xai-grok-credentials.test.ts create mode 100644 test/xai-grok-oauth.test.ts create mode 100644 test/xai-grok-provider.test.ts diff --git a/openwiki/operations/credentials-and-updates.md b/openwiki/operations/credentials-and-updates.md index 4ad87bab..a25fec72 100644 --- a/openwiki/operations/credentials-and-updates.md +++ b/openwiki/operations/credentials-and-updates.md @@ -39,6 +39,7 @@ The file stores provider configuration and API keys: - `OPENWIKI_PROVIDER_RETRY_ATTEMPTS` — optional positive integer retry count for transient provider request failures; defaults to 3 when unset - Provider API keys: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `OPENAI_COMPATIBLE_API_KEY`, `ANTHROPIC_API_KEY`, `BASETEN_API_KEY`, `FIREWORKS_API_KEY`, `GEMINI_API_KEY`, `NEBIUS_API_KEY` - ChatGPT OAuth tokens (for the `openai-chatgpt` provider): `OPENAI_CHATGPT_ACCESS_TOKEN`, `OPENAI_CHATGPT_REFRESH_TOKEN`, `OPENAI_CHATGPT_EXPIRES_AT`, `OPENAI_CHATGPT_ACCOUNT_ID`, `OPENAI_CHATGPT_EMAIL`, `OPENAI_CHATGPT_PLAN` +- xAI Grok subscription OAuth tokens (for the `xai-grok` provider): `XAI_GROK_ACCESS_TOKEN`, `XAI_GROK_REFRESH_TOKEN`, `XAI_GROK_EXPIRES_AT`. Login uses xAI’s public Grok CLI OAuth client (PKCE, no client secret) against `auth.x.ai`; chat calls go to `https://api.x.ai/v1`. Tokens are never logged. Client id/scopes may change without an official OpenWiki app registration. - Connector OAuth credentials: `OPENWIKI_GMAIL_ACCESS_TOKEN`, `OPENWIKI_GMAIL_REFRESH_TOKEN`, `OPENWIKI_GOOGLE_CLIENT_ID`, `OPENWIKI_GOOGLE_CLIENT_SECRET`, `OPENWIKI_NOTION_MCP_ACCESS_TOKEN`, `OPENWIKI_NOTION_MCP_CLIENT_ID`, `OPENWIKI_NOTION_MCP_REFRESH_TOKEN`, `OPENWIKI_SLACK_USER_TOKEN`, `OPENWIKI_SLACK_CLIENT_ID`, `OPENWIKI_SLACK_CLIENT_SECRET`, `OPENWIKI_X_ACCESS_TOKEN`, `OPENWIKI_X_CLIENT_ID`, `OPENWIKI_X_CLIENT_SECRET`, `OPENWIKI_X_REFRESH_TOKEN` - Base URLs: `ANTHROPIC_BASE_URL` (optional — routes the anthropic provider at an Anthropic-compatible endpoint other than the default API), `OPENAI_COMPATIBLE_BASE_URL` (required by the openai-compatible provider, which has no default endpoint), and `OPENAI_BASE_URL` (optional — overrides the openai provider's default endpoint) - AWS Bedrock credentials: `BEDROCK_AWS_ACCESS_KEY_ID`, `BEDROCK_AWS_SECRET_ACCESS_KEY`, `BEDROCK_AWS_REGION` (all required by the bedrock provider) @@ -74,7 +75,7 @@ queries. `src/credentials.tsx` provides the interactive bootstrap flow when required: - prompts for a provider (arrow-key selection menu), -- prompts for the provider's API key (skipped for the gemini-enterprise provider, which prompts for a required Google Cloud project ID and an optional location instead; skipped for the bedrock provider, which prompts for AWS access key ID, secret access key, and region instead), +- prompts for the provider's API key (skipped for OAuth providers such as `openai-chatgpt` and `xai-grok`, which run a browser login instead; skipped for the gemini-enterprise provider, which prompts for a required Google Cloud project ID and an optional location instead; skipped for the bedrock provider, which prompts for AWS access key ID, secret access key, and region instead), - prompts for a model choice (arrow-key selection from the provider's model list, or a custom model ID), - optionally prompts for a LangSmith key, - writes the results with restrictive file permissions, diff --git a/openwiki/quickstart.md b/openwiki/quickstart.md index f1a8e87d..e3107648 100644 --- a/openwiki/quickstart.md +++ b/openwiki/quickstart.md @@ -13,7 +13,7 @@ OpenWiki is a TypeScript CLI that writes and maintains documentation for a repos - Launches an interactive Ink-based terminal app for chatting with the OpenWiki agent. - Supports one-shot documentation runs with `--init`, `--update`, and `--print`. -- Supports multiple model providers — OpenAI (default, API key or ChatGPT OAuth login), OpenRouter, Anthropic, Gemini (AI Studio), Gemini Enterprise (Vertex AI, keyless via Google ADC), AWS Bedrock, Nebius Token Factory, Baseten, Fireworks, NVIDIA NIM, and any OpenAI-compatible gateway — each with their own credentials and model list (Gemini Enterprise uses Google ADC instead of an API key; Bedrock uses AWS access/secret keys and region). +- Supports multiple model providers — OpenAI (default, API key or ChatGPT OAuth login), Grok (xAI subscription OAuth), OpenRouter, Anthropic, Gemini (AI Studio), Gemini Enterprise (Vertex AI, keyless via Google ADC), AWS Bedrock, Nebius Token Factory, Baseten, Fireworks, NVIDIA NIM, and any OpenAI-compatible gateway — each with their own credentials and model list (Gemini Enterprise uses Google ADC instead of an API key; Bedrock uses AWS access/secret keys and region). - Uses a DeepAgents local shell backend with virtual filesystem paths rooted at the target repository. - Creates or refreshes documentation under the target repository's `openwiki/` directory. - Auto-exits after successful `--init` or `--update` runs in an interactive terminal, so the CLI works as both a one-shot and interactive tool. @@ -39,6 +39,7 @@ OpenWiki is a TypeScript CLI that writes and maintains documentation for a repos - `src/agent/types.ts` — shared agent types (`OpenWikiCommand`, `RunContext`, `UpdateMetadata`, run options/events). - `src/agent/docs-only-backend.ts` — `OpenWikiLocalShellBackend`, extends DeepAgents `LocalShellBackend` with docs-only write guards and output-mode awareness. - `src/agent/openai-chatgpt-oauth.ts` — ChatGPT OAuth flow, token persistence, and refresh logic for the `openai-chatgpt` provider. +- `src/agent/xai-grok-oauth.ts` — xAI Grok subscription OAuth (browser PKCE), token persistence, and refresh for the `xai-grok` provider. - `src/auth/oauth.ts` — generic OAuth runner for connector providers (Gmail, Notion, Slack, X). - `src/auth/providers.ts` — connector OAuth provider configs (scopes, token URLs, env-key mappings). - `src/auth/configure.ts` — `openwiki auth configure ` flow for creating local connector configs. diff --git a/src/agent/index.ts b/src/agent/index.ts index 2792ce64..7d9f63e4 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -35,6 +35,12 @@ import { readCodexTokensFromEnv, refreshChatGptTokens, } from "./openai-chatgpt-oauth.js"; +import { + ensureFreshXaiGrokTokens, + readXaiGrokTokensFromEnv, + XAI_API_BASE_URL, + XAI_GROK_LOGIN_INCOMPLETE_MESSAGE, +} from "./xai-grok-oauth.js"; import { createSystemPrompt, createUserPrompt } from "./prompt.js"; import { syncBundledSkills } from "./skills.js"; import { @@ -188,6 +194,11 @@ export async function runOpenWikiAgent( emitDebug(options, "chatgpt.token=fresh"); } + if (provider === "xai-grok") { + await ensureFreshXaiGrokTokens(); + emitDebug(options, "xai-grok.token=fresh"); + } + modelId = resolveModelId(options, provider); emitDebug(options, `model=${modelId}`); const providerRetryAttempts = resolveProviderRetryAttempts(); @@ -733,6 +744,24 @@ export function createModel( }); } + if (provider === "xai-grok") { + // Already refreshed by `ensureFreshXaiGrokTokens()` before the run started. + const tokens = readXaiGrokTokensFromEnv(); + + if (!tokens) { + throw new Error(XAI_GROK_LOGIN_INCOMPLETE_MESSAGE); + } + + return new ChatOpenAI({ + apiKey: tokens.access, + model: modelId, + ...retryOptions, + configuration: { + baseURL: XAI_API_BASE_URL, + }, + }); + } + if (provider === "openrouter") { const providerOnly = resolveOpenRouterProviderOnly(); diff --git a/src/agent/xai-grok-oauth.ts b/src/agent/xai-grok-oauth.ts new file mode 100644 index 00000000..3a266dea --- /dev/null +++ b/src/agent/xai-grok-oauth.ts @@ -0,0 +1,444 @@ +import { createHash, randomBytes } from "node:crypto"; +import http from "node:http"; +import { + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, +} from "../constants.js"; +import { saveOpenWikiEnv } from "../env.js"; + +/** + * xAI Grok subscription OAuth client. + * + * Uses the public Grok CLI OAuth client (no secret; PKCE) against auth.x.ai and + * the OpenAI-compatible chat API at api.x.ai. This is unofficial relative to a + * dedicated OpenWiki app registration — client id, scopes, or endpoints may + * change. Keep constants centralized here. + */ + +/** Public Grok CLI OAuth client id (no client secret; PKCE public client). */ +const XAI_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; +const XAI_OAUTH_SCOPE = + "openid profile email offline_access grok-cli:access api:access"; +const XAI_ISSUER = "https://auth.x.ai"; +const XAI_AUTHORIZE_URL = `${XAI_ISSUER}/oauth2/authorize`; +const XAI_TOKEN_URL = `${XAI_ISSUER}/oauth2/token`; +const XAI_REFERRER = "openwiki"; + +/** Dedicated loopback port so ChatGPT (1455) and connector OAuth (53682) stay free. */ +const DEFAULT_CALLBACK_PORT = 1456; +const CALLBACK_HOST = "127.0.0.1"; +const CALLBACK_PATH = "/callback"; +const OAUTH_CALLBACK_PORT_ENV_KEY = "OPENWIKI_OAUTH_CALLBACK_PORT"; + +/** OpenAI-compatible chat base URL; LangChain appends `/chat/completions`. */ +export const XAI_API_BASE_URL = "https://api.x.ai/v1"; + +/** + * Refresh the access token when it is within this many milliseconds of expiry, + * so a token does not lapse mid-run. + */ +export const XAI_GROK_TOKEN_REFRESH_THRESHOLD_MS = 60_000; + +export const XAI_GROK_LOGIN_INCOMPLETE_MESSAGE = + "xAI Grok login is incomplete. Run `openwiki --init` to sign in with your xAI account."; + +export interface XaiGrokTokens { + access: string; + refresh: string; + /** Absolute expiry time of the access token, in epoch milliseconds. */ + expiresAtMs: number; +} + +export interface XaiGrokLoginHandle { + /** + * Complete the login from a manually pasted value — either the full redirect + * URL the browser landed on or the bare `code`. Returns `null` on success, or + * a human-readable error string if the input can't be used. + */ + submitManual(input: string): string | null; +} + +/** + * The single source of truth for how {@link XaiGrokTokens} maps onto the + * `~/.openwiki/.env` keys. + */ +export function xaiGrokTokensToEnv( + tokens: XaiGrokTokens, +): Record { + return { + [XAI_GROK_ACCESS_TOKEN_ENV_KEY]: tokens.access, + [XAI_GROK_REFRESH_TOKEN_ENV_KEY]: tokens.refresh, + [XAI_GROK_EXPIRES_AT_ENV_KEY]: String(tokens.expiresAtMs), + }; +} + +/** + * Reads persisted {@link XaiGrokTokens} back out of the environment. Returns + * `null` unless access + refresh tokens are both present. + */ +export function readXaiGrokTokensFromEnv( + env: NodeJS.ProcessEnv = process.env, +): XaiGrokTokens | null { + const access = env[XAI_GROK_ACCESS_TOKEN_ENV_KEY]; + const refresh = env[XAI_GROK_REFRESH_TOKEN_ENV_KEY]; + + if (!access || !refresh) { + return null; + } + + return { + access, + refresh, + expiresAtMs: Number(env[XAI_GROK_EXPIRES_AT_ENV_KEY]), + }; +} + +/** Completeness + near-expiry check for wizard / startup. */ +export function hasValidXaiGrokTokens( + env: NodeJS.ProcessEnv = process.env, +): boolean { + const tokens = readXaiGrokTokensFromEnv(env); + + return tokens !== null && !isXaiGrokTokenExpired(tokens.expiresAtMs); +} + +/** + * Whether a token expiring at `expiresAtMs` should be refreshed now, accounting + * for the near-expiry threshold. + */ +export function isXaiGrokTokenExpired( + expiresAtMs: number, + now = Date.now(), + thresholdMs = XAI_GROK_TOKEN_REFRESH_THRESHOLD_MS, +): boolean { + return !Number.isFinite(expiresAtMs) || now >= expiresAtMs - thresholdMs; +} + +/** + * Extracts the `code`/`state` from a manually pasted value. Accepts a full + * redirect URL, a bare query string (`code=…&state=…`), or a bare code. + */ +export function parseManualCallbackInput(input: string): { + code: string | null; + state: string | null; +} { + const trimmed = input.trim(); + + if (/^https?:\/\//iu.test(trimmed)) { + try { + const url = new URL(trimmed); + + return { + code: url.searchParams.get("code"), + state: url.searchParams.get("state"), + }; + } catch { + return { code: null, state: null }; + } + } + + if (trimmed.includes("code=")) { + const params = new URLSearchParams( + trimmed.startsWith("?") ? trimmed.slice(1) : trimmed, + ); + + return { code: params.get("code"), state: params.get("state") }; + } + + return { code: trimmed.length > 0 ? trimmed : null, state: null }; +} + +function base64url(buf: Buffer): string { + return buf + .toString("base64") + .replace(/\+/gu, "-") + .replace(/\//gu, "_") + .replace(/=+$/u, ""); +} + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + + return { verifier, challenge }; +} + +function getCallbackPort(): number { + const rawPort = process.env[OAUTH_CALLBACK_PORT_ENV_KEY]; + + if (!rawPort) { + return DEFAULT_CALLBACK_PORT; + } + + if (!/^[0-9]{1,5}$/u.test(rawPort)) { + throw new Error(`${OAUTH_CALLBACK_PORT_ENV_KEY} must be a TCP port.`); + } + + const port = Number(rawPort); + + if (!Number.isInteger(port) || port < 1024 || port > 65535) { + throw new Error( + `${OAUTH_CALLBACK_PORT_ENV_KEY} must be between 1024 and 65535.`, + ); + } + + return port; +} + +type TokenJson = { + access_token?: string; + refresh_token?: string; + expires_in?: number; + error?: string; + error_description?: string; +}; + +async function parseTokenResponse( + res: Response, + previousRefresh?: string, +): Promise { + let json: TokenJson; + + try { + json = (await res.json()) as TokenJson; + } catch { + throw new Error( + `xAI token request failed (${res.status}). Try signing in again.`, + ); + } + + if (!res.ok) { + const detail = json.error_description ?? json.error; + + if (json.error === "invalid_grant") { + throw new Error( + "xAI session expired or was revoked. Sign in again with `openwiki --init`.", + ); + } + + throw new Error( + detail + ? `xAI token request failed (${res.status}): ${detail}` + : `xAI token request failed (${res.status}). Try signing in again.`, + ); + } + + if (!json.access_token) { + throw new Error( + "xAI token response missing required fields: access_token.", + ); + } + + const refresh = json.refresh_token ?? previousRefresh; + + if (!refresh) { + throw new Error( + "xAI token response missing required fields: refresh_token.", + ); + } + + if (json.expires_in === undefined || json.expires_in === null) { + throw new Error("xAI token response missing required fields: expires_in."); + } + + return { + access: json.access_token, + refresh, + expiresAtMs: Date.now() + Number(json.expires_in) * 1000, + }; +} + +async function exchangeAuthorizationCode(params: { + code: string; + codeVerifier: string; + redirectUri: string; +}): Promise { + const res = await fetch(XAI_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: XAI_OAUTH_CLIENT_ID, + code: params.code, + code_verifier: params.codeVerifier, + redirect_uri: params.redirectUri, + }), + }); + + return parseTokenResponse(res); +} + +/** + * Exchanges a refresh token for a fresh access token. xAI may rotate the + * refresh token, so callers must persist whatever `refresh` comes back. + */ +export async function refreshXaiGrokTokens( + refreshToken: string, +): Promise { + const res = await fetch(XAI_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: XAI_OAUTH_CLIENT_ID, + }), + }); + + return parseTokenResponse(res, refreshToken); +} + +let refreshInFlight: Promise | null = null; + +/** + * Refreshes persisted xAI tokens when near expiry, writing rotated tokens back + * to `~/.openwiki/.env` (and `process.env`). Single-flight so concurrent callers + * share one refresh. + */ +export async function ensureFreshXaiGrokTokens(): Promise { + const tokens = readXaiGrokTokensFromEnv(); + + if (!tokens) { + throw new Error(XAI_GROK_LOGIN_INCOMPLETE_MESSAGE); + } + + if (!isXaiGrokTokenExpired(tokens.expiresAtMs)) { + return tokens; + } + + if (!refreshInFlight) { + refreshInFlight = (async () => { + const refreshed = await refreshXaiGrokTokens(tokens.refresh); + await saveOpenWikiEnv(xaiGrokTokensToEnv(refreshed)); + + return refreshed; + })().finally(() => { + refreshInFlight = null; + }); + } + + return refreshInFlight; +} + +const LOGIN_SUCCESS_HTML = + "" + + "" + + "OpenWiki" + + "

OpenWiki xAI login complete. You can close this tab.

"; + +/** + * Runs the browser Authorization Code + PKCE login. `openUrl` is invoked once + * the local callback server is listening. When loopback cannot receive the + * redirect, paste the callback URL or code via {@link XaiGrokLoginHandle}. + */ +export async function loginWithXaiGrok( + openUrl: (url: string) => void, + onReady?: (handle: XaiGrokLoginHandle) => void, +): Promise { + const { verifier, challenge } = generatePkce(); + const state = randomBytes(16).toString("hex"); + const callbackPort = getCallbackPort(); + const redirectUri = `http://${CALLBACK_HOST}:${callbackPort}${CALLBACK_PATH}`; + + const authUrl = new URL(XAI_AUTHORIZE_URL); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("client_id", XAI_OAUTH_CLIENT_ID); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("scope", XAI_OAUTH_SCOPE); + authUrl.searchParams.set("code_challenge", challenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("state", state); + authUrl.searchParams.set("referrer", XAI_REFERRER); + // Grok CLI commonly sends plan=generic; keep unless xAI rejects it. + authUrl.searchParams.set("plan", "generic"); + + const code = await new Promise((resolve, reject) => { + let settled = false; + + const finish = (authCode: string): void => { + if (settled) { + return; + } + + settled = true; + server.close(); + resolve(authCode); + }; + + const fail = (error: Error): void => { + if (settled) { + return; + } + + settled = true; + server.close(); + reject(error); + }; + + const server = http.createServer((req, res) => { + const url = new URL( + req.url ?? "", + `http://${CALLBACK_HOST}:${callbackPort}`, + ); + + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404).end(); + return; + } + + // Bad requests don't abort the login — the manual-paste path may still + // complete it — so respond with an error but keep waiting. + if (url.searchParams.get("state") !== state) { + res.writeHead(400).end("State mismatch"); + return; + } + + const authCode = url.searchParams.get("code"); + + if (!authCode) { + res.writeHead(400).end("Missing authorization code"); + return; + } + + res + .writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Content-Security-Policy": + "default-src 'none'; style-src 'unsafe-inline'", + }) + .end(LOGIN_SUCCESS_HTML); + finish(authCode); + }); + + // Loopback only: never bind an unauthenticated code-capture endpoint to a + // public interface. + server.listen(callbackPort, CALLBACK_HOST, () => { + openUrl(authUrl.toString()); + onReady?.({ + submitManual(rawInput) { + const { code: manualCode, state: manualState } = + parseManualCallbackInput(rawInput); + + if (!manualCode) { + return "Could not find an authorization code in that input."; + } + + if (manualState !== null && manualState !== state) { + return "State mismatch — paste the URL from this login attempt."; + } + + finish(manualCode); + return null; + }, + }); + }); + server.on("error", fail); + }); + + return exchangeAuthorizationCode({ + code, + codeVerifier: verifier, + redirectUri, + }); +} diff --git a/src/constants.ts b/src/constants.ts index 55d168ac..923e188e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,6 +20,9 @@ export const OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY = "OPENAI_CHATGPT_EXPIRES_AT"; export const OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY = "OPENAI_CHATGPT_ACCOUNT_ID"; export const OPENAI_CHATGPT_EMAIL_ENV_KEY = "OPENAI_CHATGPT_EMAIL"; export const OPENAI_CHATGPT_PLAN_ENV_KEY = "OPENAI_CHATGPT_PLAN"; +export const XAI_GROK_ACCESS_TOKEN_ENV_KEY = "XAI_GROK_ACCESS_TOKEN"; +export const XAI_GROK_REFRESH_TOKEN_ENV_KEY = "XAI_GROK_REFRESH_TOKEN"; +export const XAI_GROK_EXPIRES_AT_ENV_KEY = "XAI_GROK_EXPIRES_AT"; export const ANTHROPIC_API_KEY_ENV_KEY = "ANTHROPIC_API_KEY"; export const ANTHROPIC_BASE_URL_ENV_KEY = "ANTHROPIC_BASE_URL"; export const OPENROUTER_API_KEY_ENV_KEY = "OPENROUTER_API_KEY"; @@ -94,7 +97,8 @@ export type OpenWikiProvider = | "openai" | "openai-chatgpt" | "openai-compatible" - | "openrouter"; + | "openrouter" + | "xai-grok"; /** * How a provider authenticates. Providers default to `"api-key"` (a pasted @@ -139,6 +143,14 @@ const GEMINI_MODELS: ProviderModelOption[] = [ { id: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash-Lite" }, ]; +/** Static chat-capable Grok presets for the xAI subscription OAuth provider. */ +const XAI_GROK_MODEL_OPTIONS: ProviderModelOption[] = [ + { id: "grok-4.5", label: "Grok 4.5" }, + { id: "grok-code-fast-1", label: "Grok Code Fast 1" }, + { id: "grok-4-1-fast-reasoning", label: "Grok 4.1 Fast Reasoning" }, + { id: "grok-4-1-fast-non-reasoning", label: "Grok 4.1 Fast Non-Reasoning" }, +]; + type ProviderConfig = { /** * Environment variable holding the provider's API key, or a legacy access @@ -199,6 +211,7 @@ type ProviderConfig = { export const SELECTABLE_OPENWIKI_PROVIDERS = [ "openai", "openai-chatgpt", + "xai-grok", "anthropic", "gemini", "gemini-enterprise", @@ -290,6 +303,12 @@ export const PROVIDER_CONFIGS: Record = { label: "OpenAI (ChatGPT login)", modelOptions: OPENAI_MODEL_OPTIONS, }, + "xai-grok": { + apiKeyEnvKey: XAI_GROK_ACCESS_TOKEN_ENV_KEY, + authMethod: "oauth", + label: "Grok (xAI subscription)", + modelOptions: XAI_GROK_MODEL_OPTIONS, + }, "openai-compatible": { apiKeyEnvKey: OPENAI_COMPATIBLE_API_KEY_ENV_KEY, baseUrlEnvKey: OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, @@ -506,6 +525,14 @@ export function getProviderCredentialHint( ); } + if (provider === "openai-chatgpt") { + return "Sign in with your ChatGPT account via `openwiki --init`."; + } + + if (provider === "xai-grok") { + return "Sign in with your xAI account via `openwiki --init`."; + } + return null; } diff --git a/src/credentials.tsx b/src/credentials.tsx index 0bbf3937..6c004914 100644 --- a/src/credentials.tsx +++ b/src/credentials.tsx @@ -58,6 +58,16 @@ import { loginWithChatGPT, readCodexTokensFromEnv, } from "./agent/openai-chatgpt-oauth.js"; +import { + type XaiGrokLoginHandle, + type XaiGrokTokens, + hasValidXaiGrokTokens, + loginWithXaiGrok, + xaiGrokTokensToEnv, +} from "./agent/xai-grok-oauth.js"; + +type OAuthTokenSet = CodexTokens | XaiGrokTokens; +type OAuthLoginHandle = ChatGptLoginHandle | XaiGrokLoginHandle; import type { AuthProviderId } from "./auth/types.js"; import type { OpenWikiRunMode } from "./commands.js"; import { @@ -504,7 +514,7 @@ function getAwsCredentialRepairMessage( */ function needsCredentialStep(provider: OpenWikiProvider): boolean { if (providerUsesOAuth(provider)) { - return !hasValidStoredToken(); + return !hasValidStoredToken(provider); } return ( @@ -629,10 +639,56 @@ export function nextSetupStep( return index >= 0 && index + 1 < spine.length ? spine[index + 1] : null; } -function hasValidStoredToken(env: NodeJS.ProcessEnv = process.env): boolean { - const tokens = readCodexTokensFromEnv(env); +function hasValidStoredToken( + provider: OpenWikiProvider = resolveConfiguredProvider(), + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (provider === "xai-grok") { + return hasValidXaiGrokTokens(env); + } + + if (provider === "openai-chatgpt") { + const tokens = readCodexTokensFromEnv(env); + + return tokens !== null && !isChatGptTokenExpired(tokens.expiresAtMs); + } - return tokens !== null && !isChatGptTokenExpired(tokens.expiresAtMs); + return false; +} + +function oauthTokensToEnv( + provider: OpenWikiProvider, + tokens: OAuthTokenSet, +): Record { + if (provider === "xai-grok") { + return xaiGrokTokensToEnv(tokens as XaiGrokTokens); + } + + return codexTokensToEnv(tokens as CodexTokens); +} + +async function loginWithProviderOAuth( + provider: OpenWikiProvider, + openUrl: (url: string) => void, + onReady?: (handle: OAuthLoginHandle) => void, +): Promise { + if (provider === "xai-grok") { + return loginWithXaiGrok(openUrl, onReady); + } + + if (provider === "openai-chatgpt") { + return loginWithChatGPT(openUrl, onReady); + } + + throw new Error(`OAuth login is not supported for provider ${provider}.`); +} + +function getOAuthLoginLabel(provider: OpenWikiProvider): string { + if (provider === "xai-grok") { + return "xAI login"; + } + + return "ChatGPT login"; } function needsGcpProjectStep(provider: OpenWikiProvider): boolean { @@ -700,22 +756,31 @@ function isRegionConfigured(provider: OpenWikiProvider): boolean { function isCredentialConfigured(provider: OpenWikiProvider): boolean { return providerUsesOAuth(provider) - ? hasValidStoredToken() + ? hasValidStoredToken(provider) : getMissingProviderEnvKey(provider) === null; } function getCredentialSetupDetail( provider: OpenWikiProvider, - tokens: CodexTokens | null = null, + tokens: OAuthTokenSet | null = null, ): string { if (providerUsesOAuth(provider)) { if (!isCredentialConfigured(provider) && !tokens) { - return "sign in with your ChatGPT account"; + return provider === "xai-grok" + ? "sign in with your xAI account" + : "sign in with your ChatGPT account"; + } + + if (provider === "xai-grok") { + return "signed in with xAI"; } + const chatGptTokens = tokens as CodexTokens | null; const account = formatChatGptAccount( - tokens?.email ?? process.env[OPENAI_CHATGPT_EMAIL_ENV_KEY] ?? null, - tokens?.planType ?? process.env[OPENAI_CHATGPT_PLAN_ENV_KEY] ?? null, + chatGptTokens?.email ?? process.env[OPENAI_CHATGPT_EMAIL_ENV_KEY] ?? null, + chatGptTokens?.planType ?? + process.env[OPENAI_CHATGPT_PLAN_ENV_KEY] ?? + null, ); return account ? `signed in as ${account}` : "signed in with ChatGPT"; @@ -924,13 +989,13 @@ export function InitSetup({ const [notice, setNotice] = useState(null); const [isSaving, setIsSaving] = useState(false); const [isAuthRunning, setIsAuthRunning] = useState(false); - const [oauthTokens, setOauthTokens] = useState(null); + const [oauthTokens, setOauthTokens] = useState(null); const [loginUrl, setLoginUrl] = useState(null); const [isLoggingIn, setIsLoggingIn] = useState(false); const [loginAttempt, setLoginAttempt] = useState(0); const [copied, setCopied] = useState(false); const [forceModelStep, setForceModelStep] = useState(false); - const loginHandleRef = useRef(null); + const loginHandleRef = useRef(null); const activeSourceOptions = useMemo( () => getTemplateSourceOptions(getConfigModeId(onboardingConfig)), @@ -1066,7 +1131,8 @@ export function InitSetup({ void (async () => { try { - const tokens = await loginWithChatGPT( + const tokens = await loginWithProviderOAuth( + provider, (url) => { if (cancelled) { return; @@ -1139,7 +1205,7 @@ export function InitSetup({ return () => { cancelled = true; }; - }, [step, loginAttempt]); + }, [step, loginAttempt, provider]); /** * Pre-fill the input or selection for a step reached via navigation, so a done @@ -2611,7 +2677,7 @@ export function InitSetup({ nextGcpProject: string | null; nextLangSmithKey: string | null; nextModelId: string | null; - nextOAuthTokens?: CodexTokens | null; + nextOAuthTokens?: OAuthTokenSet | null; nextProvider: OpenWikiProvider; nextRegion: string | null; nextSecretKey: string | null; @@ -2781,7 +2847,7 @@ export function InitSetup({ } if (nextOAuthTokens) { - Object.assign(updates, codexTokensToEnv(nextOAuthTokens)); + Object.assign(updates, oauthTokensToEnv(nextProvider, nextOAuthTokens)); } if (nextBaseUrl !== null) { @@ -3229,7 +3295,9 @@ export function InitSetup({ ) : providerUsesOAuth(provider) || primaryCredentialStep ? ( - ChatGPT login + {loginLabel} - Sign in with your {getProviderLabel(provider)} account to authorize - OpenWiki. + Sign in with your {accountKind} account to authorize OpenWiki + {provider === "xai-grok" + ? " (uses xAI's public Grok CLI OAuth client)." + : "."} {loginUrl ? ( @@ -4511,7 +4584,7 @@ function OAuthLoginPrompt({ ) : ( - Starting the ChatGPT login... + Starting the {loginLabel}... )} {isLoggingIn diff --git a/src/env.ts b/src/env.ts index cc35a6c6..9990ae15 100644 --- a/src/env.ts +++ b/src/env.ts @@ -29,6 +29,9 @@ import { OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, OPENAI_CHATGPT_PLAN_ENV_KEY, OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, OPENAI_COMPATIBLE_API_KEY_ENV_KEY, OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, OPENWIKI_GOOGLE_ACCESS_TOKEN_ENV_KEY, @@ -101,6 +104,9 @@ export const MANAGED_ENV_KEYS = [ OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, OPENAI_CHATGPT_EMAIL_ENV_KEY, OPENAI_CHATGPT_PLAN_ENV_KEY, + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, OPENAI_COMPATIBLE_API_KEY_ENV_KEY, OPENAI_COMPATIBLE_BASE_URL_ENV_KEY, ANTHROPIC_API_KEY_ENV_KEY, diff --git a/src/startup.ts b/src/startup.ts index 760d1d4e..d70fd3ef 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -1,10 +1,14 @@ import { shouldCheckUpdateNoop, getUpdateNoopStatus } from "./agent/utils.js"; import { readCodexTokensFromEnv } from "./agent/openai-chatgpt-oauth.js"; +import { readXaiGrokTokensFromEnv } from "./agent/xai-grok-oauth.js"; import type { CliCommand } from "./commands.js"; import { OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, getMissingProviderEnvKey, getProviderApiKeyEnvKey, getProviderCredentialHint, @@ -97,6 +101,12 @@ function getMissingNonInteractiveProviderEnvKey( return getMissingProviderEnvKey(provider, env); } + if (provider === "xai-grok") { + return readXaiGrokTokensFromEnv(env) === null + ? (getProviderApiKeyEnvKey(provider) ?? "xAI Grok OAuth token set") + : null; + } + return readCodexTokensFromEnv(env) === null ? (getProviderApiKeyEnvKey(provider) ?? "ChatGPT OAuth token set") : null; @@ -110,6 +120,10 @@ function formatCredentialRequirement( return apiKeyEnvKey; } + if (provider === "xai-grok") { + return `A complete xAI Grok OAuth token set (${XAI_GROK_ACCESS_TOKEN_ENV_KEY}, ${XAI_GROK_REFRESH_TOKEN_ENV_KEY}, ${XAI_GROK_EXPIRES_AT_ENV_KEY})`; + } + return `A complete ChatGPT OAuth token set (${apiKeyEnvKey}, ${OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY}, ${OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY}, ${OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY})`; } diff --git a/test/create-model.test.ts b/test/create-model.test.ts index d4520b13..757a103a 100644 --- a/test/create-model.test.ts +++ b/test/create-model.test.ts @@ -3,6 +3,12 @@ import { ChatAnthropic } from "@langchain/anthropic"; import { ChatGoogle } from "@langchain/google/node"; import { ChatOpenAI } from "@langchain/openai"; import { createModel } from "../src/agent/index.ts"; +import { XAI_API_BASE_URL } from "../src/agent/xai-grok-oauth.ts"; +import { + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, +} from "../src/constants.ts"; // Constructing a LangChain chat model makes no network calls (auth/clients // resolve lazily on first request), so these assert the gemini-enterprise @@ -138,6 +144,54 @@ describe("createModel gemini (AI Studio)", () => { }); }); +describe("createModel xai-grok", () => { + const keys = [ + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, + ] as const; + let saved: Record; + + beforeEach(() => { + saved = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + process.env[XAI_GROK_ACCESS_TOKEN_ENV_KEY] = "xai-access-token"; + process.env[XAI_GROK_REFRESH_TOKEN_ENV_KEY] = "xai-refresh-token"; + process.env[XAI_GROK_EXPIRES_AT_ENV_KEY] = String( + Date.now() + 60 * 60 * 1000, + ); + }); + + afterEach(() => { + for (const key of keys) { + restoreEnv(key, saved[key]); + } + }); + + test("builds ChatOpenAI against api.x.ai with the access token", () => { + const model = createModel("xai-grok", "grok-4.5", 0); + + expect(model).toBeInstanceOf(ChatOpenAI); + expect(modelName(model)).toBe("grok-4.5"); + + const config = model as { + apiKey?: string; + clientConfig?: { baseURL?: string }; + }; + expect(config.apiKey ?? (model as { openAIApiKey?: string }).openAIApiKey).toBe( + "xai-access-token", + ); + expect(config.clientConfig?.baseURL).toBe(XAI_API_BASE_URL); + }); + + test("throws when the OAuth token set is incomplete", () => { + delete process.env[XAI_GROK_ACCESS_TOKEN_ENV_KEY]; + + expect(() => createModel("xai-grok", "grok-4.5", 0)).toThrow( + /xAI Grok login is incomplete/u, + ); + }); +}); + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key]; diff --git a/test/model-resolution.test.ts b/test/model-resolution.test.ts index 812328fc..0227bd2d 100644 --- a/test/model-resolution.test.ts +++ b/test/model-resolution.test.ts @@ -52,4 +52,16 @@ describe("resolveModelId", () => { resolveModelId({ modelId: "http://evil.example" }, "anthropic"), ).toThrow(/Invalid model ID/u); }); + + test("uses the xai-grok default preset when nothing is configured", () => { + delete process.env[OPENWIKI_MODEL_ID_ENV_KEY]; + + expect(resolveModelId({}, "xai-grok")).toBe("grok-4.5"); + }); + + test("accepts a custom Grok model id override", () => { + process.env[OPENWIKI_MODEL_ID_ENV_KEY] = "grok-custom-id"; + + expect(resolveModelId({}, "xai-grok")).toBe("grok-custom-id"); + }); }); diff --git a/test/startup.test.ts b/test/startup.test.ts index 5a1dd16a..ffcd0cc0 100644 --- a/test/startup.test.ts +++ b/test/startup.test.ts @@ -22,6 +22,9 @@ import { OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_PROVIDER_ENV_KEY, + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, } from "../src/constants.ts"; const execFileAsync = promisify(execFile); @@ -41,6 +44,9 @@ const MANAGED_ENV_KEYS = [ OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, ] as const; const originalEnv = new Map( MANAGED_ENV_KEYS.map((key) => [key, process.env[key]]), @@ -243,6 +249,39 @@ describe("resolveStartupCommand", () => { expect(result).toBe(command); }); + test("rejects non-interactive xAI Grok OAuth startup with incomplete tokens", async () => { + process.env[OPENWIKI_PROVIDER_ENV_KEY] = "xai-grok"; + process.env[XAI_GROK_ACCESS_TOKEN_ENV_KEY] = "access-token"; + process.env[XAI_GROK_EXPIRES_AT_ENV_KEY] = String( + Date.now() + 60 * 60 * 1000, + ); + + const result = await resolveStartupCommand( + updatePrintCommand({ userMessage: "refresh API docs" }), + { isStdinTTY: false }, + ); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toContain("xAI Grok OAuth token set"); + expect(result.message).toContain(XAI_GROK_REFRESH_TOKEN_ENV_KEY); + } + }); + + test("allows non-interactive xAI Grok OAuth startup with complete tokens", async () => { + process.env[OPENWIKI_PROVIDER_ENV_KEY] = "xai-grok"; + process.env[XAI_GROK_ACCESS_TOKEN_ENV_KEY] = "access-token"; + process.env[XAI_GROK_REFRESH_TOKEN_ENV_KEY] = "refresh-token"; + process.env[XAI_GROK_EXPIRES_AT_ENV_KEY] = String( + Date.now() + 60 * 60 * 1000, + ); + + const command = updatePrintCommand({ userMessage: "refresh API docs" }); + const result = await resolveStartupCommand(command, { isStdinTTY: false }); + + expect(result).toBe(command); + }); + test("allows non-interactive Bedrock startup with OIDC credentials", async () => { process.env[OPENWIKI_PROVIDER_ENV_KEY] = "bedrock"; process.env[AWS_ROLE_ARN_ENV_KEY] = diff --git a/test/xai-grok-credentials.test.ts b/test/xai-grok-credentials.test.ts new file mode 100644 index 00000000..de82224a --- /dev/null +++ b/test/xai-grok-credentials.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + getInitialStep, + getNextStepAfterProvider, + needsCredentialSetup, + orderedSetupSteps, +} from "../src/credentials.tsx"; + +const MANAGED_KEYS = [ + "OPENWIKI_PROVIDER", + "OPENWIKI_MODEL_ID", + "LANGSMITH_API_KEY", + "XAI_GROK_ACCESS_TOKEN", + "XAI_GROK_REFRESH_TOKEN", + "XAI_GROK_EXPIRES_AT", +] as const; + +const FAR_FUTURE = String(Date.now() + 60 * 60 * 1000); +const PAST = String(Date.now() - 60 * 60 * 1000); + +let snapshot: Record; + +function set(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +function storeXaiGrokTokens(expiresAt: string = FAR_FUTURE): void { + set("XAI_GROK_ACCESS_TOKEN", "access-token"); + set("XAI_GROK_REFRESH_TOKEN", "refresh-token"); + set("XAI_GROK_EXPIRES_AT", expiresAt); +} + +beforeEach(() => { + snapshot = {}; + for (const key of MANAGED_KEYS) { + snapshot[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of MANAGED_KEYS) { + set(key, snapshot[key]); + } +}); + +describe("xai-grok oauth credential step transitions", () => { + test("routes to oauth-login when no token is stored", () => { + set("OPENWIKI_PROVIDER", "xai-grok"); + + expect(getInitialStep(null, "xai-grok")).toBe("oauth-login"); + expect(getNextStepAfterProvider("xai-grok", null)).toBe("oauth-login"); + expect(needsCredentialSetup(null)).toBe(true); + }); + + test("routes to oauth-login when the stored token is expired", () => { + set("OPENWIKI_PROVIDER", "xai-grok"); + storeXaiGrokTokens(PAST); + + expect(getInitialStep(null, "xai-grok")).toBe("oauth-login"); + expect(needsCredentialSetup(null)).toBe(true); + }); + + test("routes to oauth-login when the stored token set is incomplete", () => { + set("OPENWIKI_PROVIDER", "xai-grok"); + set("XAI_GROK_ACCESS_TOKEN", "access-token"); + set("XAI_GROK_EXPIRES_AT", FAR_FUTURE); + + expect(getInitialStep(null, "xai-grok")).toBe("oauth-login"); + expect(needsCredentialSetup(null)).toBe(true); + }); + + test("skips oauth-login when a valid token is stored", () => { + set("OPENWIKI_PROVIDER", "xai-grok"); + storeXaiGrokTokens(); + + expect(getInitialStep(null, "xai-grok")).toBe("model"); + expect(getNextStepAfterProvider("xai-grok", null)).toBe("model"); + }); + + test("includes oauth-login in the setup spine", () => { + expect(orderedSetupSteps("xai-grok", "code", false)).toEqual([ + "provider", + "oauth-login", + "model", + "langsmith", + "code-repo-confirm", + ]); + }); +}); diff --git a/test/xai-grok-oauth.test.ts b/test/xai-grok-oauth.test.ts new file mode 100644 index 00000000..4e2ff4da --- /dev/null +++ b/test/xai-grok-oauth.test.ts @@ -0,0 +1,172 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + XAI_GROK_TOKEN_REFRESH_THRESHOLD_MS, + type XaiGrokTokens, + hasValidXaiGrokTokens, + isXaiGrokTokenExpired, + parseManualCallbackInput, + readXaiGrokTokensFromEnv, + refreshXaiGrokTokens, + xaiGrokTokensToEnv, +} from "../src/agent/xai-grok-oauth.ts"; + +function stubTokenResponse( + body: unknown, + status = 200, +): ReturnType { + const fetchMock = vi.fn(() => + Promise.resolve( + new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + }), + ), + ); + + vi.stubGlobal("fetch", fetchMock); + + return fetchMock; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("refreshXaiGrokTokens", () => { + test("parses tokens from the refresh response", async () => { + const fetchMock = stubTokenResponse({ + access_token: "access-next", + refresh_token: "refresh-next", + expires_in: 3600, + }); + + const before = Date.now(); + const tokens = await refreshXaiGrokTokens("refresh-prev"); + + expect(tokens.access).toBe("access-next"); + expect(tokens.refresh).toBe("refresh-next"); + expect(tokens.expiresAtMs).toBeGreaterThanOrEqual(before + 3600 * 1000); + expect(tokens.expiresAtMs).toBeLessThanOrEqual(Date.now() + 3600 * 1000); + + const [url, init] = fetchMock.mock.calls[0] as [ + string, + { body: URLSearchParams }, + ]; + expect(url).toBe("https://auth.x.ai/oauth2/token"); + const sentBody = init.body.toString(); + expect(sentBody).toContain("grant_type=refresh_token"); + expect(sentBody).toContain("refresh_token=refresh-prev"); + }); + + test("keeps the previous refresh token when the response omits one", async () => { + stubTokenResponse({ + access_token: "access-next", + expires_in: 900, + }); + + const tokens = await refreshXaiGrokTokens("refresh-prev"); + + expect(tokens.refresh).toBe("refresh-prev"); + }); + + test("throws a sign-in-again error on invalid_grant", async () => { + stubTokenResponse( + { error: "invalid_grant", error_description: "revoked" }, + 400, + ); + + await expect(refreshXaiGrokTokens("refresh-prev")).rejects.toThrow( + /Sign in again/u, + ); + }); + + test("throws on a non-2xx response", async () => { + stubTokenResponse("nope", 401); + + await expect(refreshXaiGrokTokens("refresh-prev")).rejects.toThrow( + /token request failed \(401\)/u, + ); + }); +}); + +describe("isXaiGrokTokenExpired", () => { + const now = 1_000_000; + + test("is not expired well before expiry", () => { + expect(isXaiGrokTokenExpired(now + 10 * 60 * 1000, now)).toBe(false); + }); + + test("is expired once past expiry", () => { + expect(isXaiGrokTokenExpired(now - 1, now)).toBe(true); + }); + + test("is expired within the near-expiry threshold", () => { + expect( + isXaiGrokTokenExpired(now + XAI_GROK_TOKEN_REFRESH_THRESHOLD_MS - 1, now), + ).toBe(true); + }); + + test("treats a non-numeric expiry as expired", () => { + expect(isXaiGrokTokenExpired(Number.NaN, now)).toBe(true); + }); +}); + +describe("xai grok token env contract", () => { + const tokens: XaiGrokTokens = { + access: "access-1", + refresh: "refresh-1", + expiresAtMs: 1_700_000_000_000, + }; + + test("round-trips tokens through the environment", () => { + const env = xaiGrokTokensToEnv(tokens); + + expect(env).toEqual({ + XAI_GROK_ACCESS_TOKEN: "access-1", + XAI_GROK_REFRESH_TOKEN: "refresh-1", + XAI_GROK_EXPIRES_AT: "1700000000000", + }); + expect(readXaiGrokTokensFromEnv(env)).toEqual(tokens); + }); + + test("returns null for incomplete env sets", () => { + expect( + readXaiGrokTokensFromEnv({ + XAI_GROK_ACCESS_TOKEN: "access-only", + }), + ).toBeNull(); + }); + + test("hasValidXaiGrokTokens requires a non-expired complete set", () => { + const env = { + XAI_GROK_ACCESS_TOKEN: "a", + XAI_GROK_REFRESH_TOKEN: "r", + XAI_GROK_EXPIRES_AT: String(Date.now() + 60 * 60 * 1000), + }; + + expect(hasValidXaiGrokTokens(env)).toBe(true); + expect( + hasValidXaiGrokTokens({ + ...env, + XAI_GROK_EXPIRES_AT: String(Date.now() - 1), + }), + ).toBe(false); + }); +}); + +describe("parseManualCallbackInput", () => { + test("parses redirect URLs, query strings, and bare codes", () => { + expect( + parseManualCallbackInput( + "http://127.0.0.1:1456/callback?code=abc&state=xyz", + ), + ).toEqual({ code: "abc", state: "xyz" }); + expect(parseManualCallbackInput("code=abc&state=xyz")).toEqual({ + code: "abc", + state: "xyz", + }); + expect(parseManualCallbackInput("abc")).toEqual({ + code: "abc", + state: null, + }); + }); +}); diff --git a/test/xai-grok-provider.test.ts b/test/xai-grok-provider.test.ts new file mode 100644 index 00000000..df88beb5 --- /dev/null +++ b/test/xai-grok-provider.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "vitest"; +import { + getProviderApiKeyEnvKey, + getProviderAuthMethod, + getProviderLabel, + getProviderModelOptions, + isValidProvider, + providerUsesOAuth, + SELECTABLE_OPENWIKI_PROVIDERS, + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, +} from "../src/constants.ts"; +import { MANAGED_ENV_KEYS } from "../src/env.ts"; + +describe("xai-grok provider config", () => { + test("is a valid, selectable provider", () => { + expect(isValidProvider("xai-grok")).toBe(true); + expect(SELECTABLE_OPENWIKI_PROVIDERS).toContain("xai-grok"); + }); + + test("uses oauth authentication", () => { + expect(getProviderAuthMethod("xai-grok")).toBe("oauth"); + expect(providerUsesOAuth("xai-grok")).toBe(true); + }); + + test("has the Grok subscription label", () => { + expect(getProviderLabel("xai-grok")).toBe("Grok (xAI subscription)"); + }); + + test("ships static Grok model presets", () => { + expect(getProviderModelOptions("xai-grok").map((m) => m.id)).toEqual([ + "grok-4.5", + "grok-code-fast-1", + "grok-4-1-fast-reasoning", + "grok-4-1-fast-non-reasoning", + ]); + }); + + test("its api-key env key is the access token", () => { + expect(getProviderApiKeyEnvKey("xai-grok")).toBe( + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + ); + }); + + test("exposes the token env key constants and manages them", () => { + expect(XAI_GROK_ACCESS_TOKEN_ENV_KEY).toBe("XAI_GROK_ACCESS_TOKEN"); + expect(XAI_GROK_REFRESH_TOKEN_ENV_KEY).toBe("XAI_GROK_REFRESH_TOKEN"); + expect(XAI_GROK_EXPIRES_AT_ENV_KEY).toBe("XAI_GROK_EXPIRES_AT"); + + for (const key of [ + XAI_GROK_ACCESS_TOKEN_ENV_KEY, + XAI_GROK_REFRESH_TOKEN_ENV_KEY, + XAI_GROK_EXPIRES_AT_ENV_KEY, + ]) { + expect(MANAGED_ENV_KEYS).toContain(key); + } + }); +});