From 5785a1f215b12cd7446a72106e065fc7af6c75af Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 1 Aug 2026 21:49:23 -0300 Subject: [PATCH 1/5] Collapse OAuth token persistence into one updater - Rename `schema_OpenAITokens` -> `schema_BearerTokens` and `OpenAITokens` -> `BearerTokens`, updating importers in `src/infra/auth/openai.ts` and `src/infra/storage/config.ts`, since the shape is not OpenAI-specific. - Replace `updateGoogleTokens` and `updateOpenAITokens` with a single `updateOAuthTokens` taking the tagged `RefreshableAuthMethod` variant, so the tag and payload cannot disagree and the two exhaustive `auth_method.type` switches disappear. - Derive `RefreshableAuthMethod` structurally from `RefreshTokens` so it widens on its own when a refreshable auth variant is added. - Pass the tagged variant from `resolveProvider` in `src/domain/llm/auth-resolver.ts`. - Replace the `google_oauth || openai_oauth` boolean chain in `src/cli/doctor.ts` with an exhaustive `tokenExpiry` helper returning `Maybe`, so a new auth variant is a compile error rather than a silently missing Token Status row. - Return `absurd` from the `authMethodLabel` default instead of a placeholder string, for the same reason. - Delete the unused `AI_PROVIDERS` export, which had no importers and did not drive the setup picker. - Cover `updateOAuthTokens` with tests for the matching, mismatched, and sibling-field-preservation cases, which neither original updater had. --- src/cli/doctor.ts | 43 ++++++++++++++++-------- src/domain/config/config.ts | 15 ++++----- src/domain/llm/auth-resolver.ts | 10 +++--- src/infra/auth/openai.ts | 14 ++++---- src/infra/storage/config.ts | 47 ++++++--------------------- test/domain/llm/auth-resolver.test.ts | 10 +++--- test/infra/storage/config.test.ts | 38 ++++++++++++++++++++-- 7 files changed, 99 insertions(+), 78 deletions(-) diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index e6f3db2..a39d5fd 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -6,7 +6,7 @@ import * as repo from "@/infra/git/repo"; import { Future } from "@/libs/future"; import { configFile, loadConfig } from "@/infra/storage/config"; import { type AuthMethod, type ProviderConfig } from "@/domain/config/config"; -import { Just, type Maybe } from "@/libs/maybe"; +import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { absurd } from "@/libs/types"; import { access } from "node:fs/promises"; import { environment } from "@/infra/env"; @@ -82,18 +82,19 @@ class Doctor { rows.push(["Auth Method", color.green(authMethodLabel(authMethod)), authMethodDescription(ai)]); - if (ai.auth_method.type === "google_oauth" || ai.auth_method.type === "openai_oauth") { - const now = Date.now(); - const expiryDate = ai.auth_method.content.expiry_date; // For API Key we don't have this, that's why we have this `if (...) {}` block - const isExpired = expiryDate <= now; - const expiryStr = new Date(expiryDate).toLocaleString(); - - rows.push([ - "Token Status", - isExpired ? color.yellow("Expired") : color.green("Valid"), - isExpired ? `Expired at ${expiryStr} (will auto-refresh)` : `Expires at ${expiryStr}` - ]); - } + rows.push( + ...tokenExpiry(ai.auth_method).maybe([], (expiryDate) => { + const isExpired = expiryDate <= Date.now(); + const expiryStr = new Date(expiryDate).toLocaleString(); + return [ + [ + "Token Status", + isExpired ? color.yellow("Expired") : color.green("Valid"), + isExpired ? `Expired at ${expiryStr} (will auto-refresh)` : `Expires at ${expiryStr}` + ] + ]; + }) + ); return rows; }) @@ -165,6 +166,20 @@ function renderModelInfo(ai: ProviderConfig): string { return ai.effort instanceof Just ? `${base} (${ai.effort.value} effort)` : base; } +/** `Nothing` for auth methods that carry no expiry, so a new variant is a compile error rather than a missing row. */ +function tokenExpiry(authMethod: ProviderConfig["auth_method"]): Maybe { + switch (authMethod.type) { + case "google_oauth": + case "openai_oauth": + return Just(authMethod.content.expiry_date); + case "api_key": + case "anthropic_setup_token": + return Nothing(); + default: + return absurd(authMethod, "AuthMethod"); + } +} + function authMethodLabel(authMethod: AuthMethod): string { switch (authMethod) { case "google_oauth": @@ -175,7 +190,7 @@ function authMethodLabel(authMethod: AuthMethod): string { case "api_key": return "API Key"; default: - return "This should never happen. Please run 'commit-tools setup' to create a new configuration."; + return absurd(authMethod, "AuthMethod"); } } diff --git a/src/domain/config/config.ts b/src/domain/config/config.ts index 31386f3..fc8bfe6 100644 --- a/src/domain/config/config.ts +++ b/src/domain/config/config.ts @@ -1,7 +1,7 @@ export { type CommitConvention, type OAuthTokens, - type OpenAITokens, + type BearerTokens, type RefreshTokens, type AuthMethod, type ProviderConfig, @@ -12,11 +12,10 @@ export { type Model, Config, schema_OAuthTokens, - schema_OpenAITokens, + schema_BearerTokens, schema_AuthMethod, schema_ProviderConfig, resolveAuthMethod, - AI_PROVIDERS, COMMIT_CONVENTIONS, OPENAI_EFFORTS, ANTHROPIC_EFFORTS, @@ -35,8 +34,6 @@ import type AnthropicPkg from "@anthropic-ai/sdk"; const COMMIT_CONVENTIONS = ["conventional", "imperative", "custom"] as const; type CommitConvention = (typeof COMMIT_CONVENTIONS)[number]; -const AI_PROVIDERS = ["gemini", "openai", "anthropic"] as const; - const schema_OAuthTokens = s.object({ access_token: s.string, refresh_token: s.string, @@ -46,14 +43,14 @@ const schema_OAuthTokens = s.object({ }); type OAuthTokens = s.Infer; -const schema_OpenAITokens = s.object({ +const schema_BearerTokens = s.object({ access_token: s.string, refresh_token: s.string, expiry_date: s.number }); -type OpenAITokens = s.Infer; +type BearerTokens = s.Infer; -type RefreshTokens = OAuthTokens | OpenAITokens; +type RefreshTokens = OAuthTokens | BearerTokens; const schema_AuthMethod = s.discriminatedUnion([ s.variant({ @@ -66,7 +63,7 @@ const schema_AuthMethod = s.discriminatedUnion([ }), s.variant({ type: "openai_oauth", - content: schema_OpenAITokens + content: schema_BearerTokens }), s.variant({ type: "anthropic_setup_token", diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index 5927dc9..a932459 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -5,7 +5,7 @@ import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { resolveAuthMethod, type Config, type ProviderConfig, type RefreshTokens } from "@/domain/config/config"; import { ensureFreshTokens } from "@/infra/auth/google"; import { ensureFreshOpenAITokens } from "@/infra/auth/openai"; -import { updateGoogleTokens, updateOpenAITokens } from "@/infra/storage/config"; +import { updateOAuthTokens } from "@/infra/storage/config"; import { absurd } from "@/libs/types"; type DetectTokenChange = (original: T, fresh: T) => Maybe; @@ -35,13 +35,13 @@ const resolveProvider: ResolveProvider = (config) => { return Future.resolve(ai); case "google_oauth": - return refreshAndPersist(ai.auth_method.content, ensureFreshTokens, updateGoogleTokens).map((tokens) => - resolveAuthMethod(ai, { type: "google_oauth", content: tokens }) + return refreshAndPersist(ai.auth_method.content, ensureFreshTokens, (content) => updateOAuthTokens({ type: "google_oauth", content })).map( + (tokens) => resolveAuthMethod(ai, { type: "google_oauth", content: tokens }) ); case "openai_oauth": - return refreshAndPersist(ai.auth_method.content, ensureFreshOpenAITokens, updateOpenAITokens).map((tokens) => - resolveAuthMethod(ai, { type: "openai_oauth", content: tokens }) + return refreshAndPersist(ai.auth_method.content, ensureFreshOpenAITokens, (content) => updateOAuthTokens({ type: "openai_oauth", content })).map( + (tokens) => resolveAuthMethod(ai, { type: "openai_oauth", content: tokens }) ); default: diff --git a/src/infra/auth/openai.ts b/src/infra/auth/openai.ts index 647c7bd..950374a 100644 --- a/src/infra/auth/openai.ts +++ b/src/infra/auth/openai.ts @@ -1,6 +1,6 @@ export { performOpenAIOAuthFlow, ensureFreshOpenAITokens, validateOpenAITokens, getOpenAIAccessToken }; -import { type OpenAITokens } from "@/domain/config/config"; +import { type BearerTokens } from "@/domain/config/config"; import { SUCCESS_HTML, ERROR_HTML } from "@/infra/auth/templates"; import { Future } from "@/libs/future"; import { randomBytes, createHash } from "node:crypto"; @@ -114,7 +114,7 @@ const openBrowser = (url: string): Future => return Future.resolve(undefined); }); -const exchangeCodeForTokens = (code: string, codeVerifier: string, redirectUri: string): Future => +const exchangeCodeForTokens = (code: string, codeVerifier: string, redirectUri: string): Future => Future.attemptP(async () => { const body = new URLSearchParams({ grant_type: "authorization_code", @@ -152,7 +152,7 @@ const exchangeCodeForTokens = (code: string, codeVerifier: string, redirectUri: }; }).mapRej((e) => new Error(`Token exchange failed: ${e}`)); -const performOpenAIOAuthFlow = (): Future => +const performOpenAIOAuthFlow = (): Future => findAvailablePort().chain((port) => { const redirectUri = `http://localhost:${port}/auth/callback`; const codeVerifier = generateCodeVerifier(); @@ -171,7 +171,7 @@ const performOpenAIOAuthFlow = (): Future => authUrl.searchParams.set("state", state); authUrl.searchParams.set("originator", "codex_cli_rs"); - return Future.bracket(startCallbackServer(port, state), stopCallbackServer, (cs) => { + return Future.bracket(startCallbackServer(port, state), stopCallbackServer, (cs) => { const waitForCode: Future = openBrowser(authUrl.toString()).chain(() => Future.attemptP(() => cs.codePromise)); const timeout: Future = Future.create((reject) => { @@ -183,7 +183,7 @@ const performOpenAIOAuthFlow = (): Future => }); }); -const ensureFreshOpenAITokens = (tokens: OpenAITokens): Future => { +const ensureFreshOpenAITokens = (tokens: BearerTokens): Future => { const isExpired = tokens.expiry_date <= Date.now() + TOKEN_REFRESH_BUFFER_MS; if (!isExpired) { @@ -230,8 +230,8 @@ const ensureFreshOpenAITokens = (tokens: OpenAITokens): Future => +const validateOpenAITokens = (tokens: BearerTokens): Future => tokens.access_token && tokens.access_token.length > 0 ? Future.resolve(undefined) : Future.reject(new Error("No valid OpenAI access token available")); -const getOpenAIAccessToken = (tokens: OpenAITokens): Future => +const getOpenAIAccessToken = (tokens: BearerTokens): Future => tokens.access_token ? Future.resolve(tokens.access_token) : Future.reject(new Error("No OpenAI access token provided")); diff --git a/src/infra/storage/config.ts b/src/infra/storage/config.ts index 3400c64..42163fc 100644 --- a/src/infra/storage/config.ts +++ b/src/infra/storage/config.ts @@ -1,4 +1,4 @@ -export { loadConfig, saveConfig, updateGoogleTokens, updateOpenAITokens, configDir, configFile }; +export { loadConfig, saveConfig, updateOAuthTokens, configDir, configFile }; import * as s from "@/libs/json/schema"; @@ -7,8 +7,7 @@ import { Success, Failure, type Result } from "@/libs/result"; import { resolve } from "node:path"; import { homedir } from "node:os"; import { readFile, writeFile, mkdir } from "node:fs/promises"; -import { Config, resolveAuthMethod, type OAuthTokens, type OpenAITokens } from "@/domain/config/config"; -import { absurd } from "@/libs/types"; +import { Config, resolveAuthMethod, type RefreshTokens } from "@/domain/config/config"; const configDir = (): string => (process.env["COMMIT_TOOLS_HOME"] ? resolve(process.env["COMMIT_TOOLS_HOME"]) : resolve(homedir(), ".commit-tools")); const configFile = (): string => resolve(configDir(), "config.json"); @@ -45,38 +44,12 @@ const saveConfig = (config: Config): Future => await writeFile(configFile(), JSON.stringify(s.encode(Config, config), null, 2), "utf-8"); }); -const updateGoogleTokens = (tokens: OAuthTokens): Future => - loadConfig().chain((config) => { - switch (config.ai.auth_method.type) { - case "google_oauth": - return saveConfig({ - ai: resolveAuthMethod(config.ai, { type: "google_oauth", content: tokens }), - commit_convention: config.commit_convention, - custom_template: config.custom_template - }); - case "api_key": - case "openai_oauth": - case "anthropic_setup_token": - return Future.reject(new Error("Cannot update tokens: not using Google OAuth authentication")); - default: - return absurd(config.ai.auth_method, "AuthMethod"); - } - }); +/** The auth variants whose `content` is a refreshable token pair. Widens on its own when a new one is added. */ +type RefreshableAuthMethod = Extract; -const updateOpenAITokens = (tokens: OpenAITokens): Future => - loadConfig().chain((config) => { - switch (config.ai.auth_method.type) { - case "openai_oauth": - return saveConfig({ - ai: resolveAuthMethod(config.ai, { type: "openai_oauth", content: tokens }), - commit_convention: config.commit_convention, - custom_template: config.custom_template - }); - case "api_key": - case "google_oauth": - case "anthropic_setup_token": - return Future.reject(new Error("Cannot update tokens: not using OpenAI OAuth authentication")); - default: - return absurd(config.ai.auth_method, "AuthMethod"); - } - }); +const updateOAuthTokens = (auth_method: RefreshableAuthMethod): Future => + loadConfig().chain((config) => + config.ai.auth_method.type === auth_method.type ? + saveConfig({ ...config, ai: resolveAuthMethod(config.ai, auth_method) }) + : Future.reject(new Error(`Cannot update tokens: config is not using ${auth_method.type} authentication`)) + ); diff --git a/test/domain/llm/auth-resolver.test.ts b/test/domain/llm/auth-resolver.test.ts index 0964617..79d8225 100644 --- a/test/domain/llm/auth-resolver.test.ts +++ b/test/domain/llm/auth-resolver.test.ts @@ -11,8 +11,7 @@ vi.mock("@/infra/auth/google", () => ({ })); vi.mock("@/infra/auth/openai", () => ({ ensureFreshOpenAITokens: vi.fn() })); vi.mock("@/infra/storage/config", () => ({ - updateGoogleTokens: vi.fn(() => Future.resolve(undefined)), - updateOpenAITokens: vi.fn(() => Future.resolve(undefined)) + updateOAuthTokens: vi.fn(() => Future.resolve(undefined)) })); type ConfigValue = s.Infer; @@ -66,8 +65,11 @@ describe("resolveProvider", () => { }); it("persists google tokens when refresh changes access_token", async () => { - const { updateGoogleTokens } = await import("@/infra/storage/config"); + const { updateOAuthTokens } = await import("@/infra/storage/config"); await runFuture(resolveProvider(googleConfig())); - expect(updateGoogleTokens).toHaveBeenCalledOnce(); + expect(updateOAuthTokens).toHaveBeenCalledOnce(); + expect(updateOAuthTokens).toHaveBeenCalledWith( + expect.objectContaining({ type: "google_oauth", content: expect.objectContaining({ access_token: "new-access" }) }) + ); }); }); diff --git a/test/infra/storage/config.test.ts b/test/infra/storage/config.test.ts index 4c9e29c..7f01b03 100644 --- a/test/infra/storage/config.test.ts +++ b/test/infra/storage/config.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach } from "vitest"; import { readFile } from "node:fs/promises"; -import { loadConfig, saveConfig, configFile } from "@/infra/storage/config"; -import { Nothing } from "@/libs/maybe"; +import { loadConfig, saveConfig, updateOAuthTokens, configFile } from "@/infra/storage/config"; +import { Just, Nothing } from "@/libs/maybe"; import { runFuture } from "@test/helpers/run-future"; import * as s from "@/libs/json/schema"; import { Config } from "@/domain/config/config"; @@ -19,6 +19,8 @@ const sampleConfig = (): ConfigValue => ({ } }); +const staleTokens = () => ({ access_token: "stale", refresh_token: "r1", expiry_date: 1 }); + describe("config storage", () => { beforeEach(async () => { await runFuture(saveConfig(sampleConfig())); @@ -31,6 +33,38 @@ describe("config storage", () => { expect(JSON.parse(raw).ai.provider).toBe("openai"); }); + it("updateOAuthTokens persists new tokens when the config uses that auth method", async () => { + await runFuture(saveConfig({ ...sampleConfig(), ai: { ...sampleConfig().ai, auth_method: { type: "openai_oauth", content: staleTokens() } } })); + + await runFuture(updateOAuthTokens({ type: "openai_oauth", content: { access_token: "fresh", refresh_token: "r2", expiry_date: 2 } })); + + const loaded = await runFuture(loadConfig()); + if (loaded.ai.auth_method.type !== "openai_oauth") throw new Error("expected openai_oauth"); + expect(loaded.ai.auth_method.content.access_token).toBe("fresh"); + expect(loaded.ai.auth_method.content.expiry_date).toBe(2); + }); + + it("updateOAuthTokens rejects when the config uses a different auth method", async () => { + await expect(runFuture(updateOAuthTokens({ type: "openai_oauth", content: staleTokens() }))).rejects.toThrow(/not using openai_oauth/); + }); + + it("updateOAuthTokens leaves sibling config fields untouched", async () => { + await runFuture( + saveConfig({ + commit_convention: "imperative", + custom_template: Just("tpl"), + ai: { ...sampleConfig().ai, auth_method: { type: "openai_oauth", content: staleTokens() } } + }) + ); + + await runFuture(updateOAuthTokens({ type: "openai_oauth", content: { access_token: "fresh", refresh_token: "r2", expiry_date: 2 } })); + + const loaded = await runFuture(loadConfig()); + expect(loaded.commit_convention).toBe("imperative"); + expect(loaded.custom_template).toBeInstanceOf(Just); + expect(loaded.ai.model).toBe("gpt-4.1-mini"); + }); + it("rejects invalid JSON on load with a clear error", async () => { const { writeFile, mkdir } = await import("node:fs/promises"); const { dirname } = await import("node:path"); From 3aaaf2fba40a94a2e8acd8c153e4475ecd422e5b Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 1 Aug 2026 21:54:45 -0300 Subject: [PATCH 2/5] Add xAI as a provider authenticated with an API key - Add the `xai` variant to `schema_ProviderConfig` with `XAI_EFFORTS` (`low`, `high`), checked with `satisfies` against the OpenAI SDK's own reasoning effort type. - Add `src/infra/auth/xai.ts` exposing `xaiApiKeyOptions`, which points the `openai` client at `https://api.x.ai/v1`; xAI's API is OpenAI-compatible so no new SDK is needed. - Add `src/infra/llm/xai.ts` calling `chat.completions` non-streaming, since the response carries both the message and usage in one payload. - Retry once without `reasoning_effort` when Grok rejects it, because support is per-model and `/v1/models` does not advertise it; skip the retry when no effort was sent. - Route `xai` through `generateContent` and report its effort as `provider default`, matching what the client reports after an effort-stripping retry. - Add `xai` arms to `seedProviderConfig`, `withModel`, `selectEffortForProvider`, `selectXaiEffort`, and `fetchModels`. - Offer xAI in `commit setup` and label its key in `commit doctor`. - Script the `@clack/prompts` `select` mock per test in `test/cli/setup.test.ts` so wizard tests no longer share one positional chain. - Cover the xAI client, model listing, router dispatch, config round-trip, and setup wizard with tests. - Document xAI in the setup prompts and providers list in `README.md`. --- README.md | 4 +- src/cli/doctor.ts | 2 + src/cli/setup.ts | 15 ++- src/domain/commit/models.ts | 30 +++++- src/domain/config/config.ts | 12 +++ src/domain/llm/effort.ts | 24 ++++- src/domain/llm/router.ts | 4 + src/infra/auth/xai.ts | 8 ++ src/infra/llm/xai.ts | 78 ++++++++++++++ src/infra/ui/effort-picker.ts | 9 +- test/cli/setup.test.ts | 30 +++++- test/domain/commit/models.test.ts | 39 ++++++- test/domain/config/config.test.ts | 24 +++++ test/domain/llm/router.test.ts | 5 +- test/infra/llm/xai.test.ts | 168 ++++++++++++++++++++++++++++++ 15 files changed, 440 insertions(+), 12 deletions(-) create mode 100644 src/infra/auth/xai.ts create mode 100644 src/infra/llm/xai.ts create mode 100644 test/infra/llm/xai.test.ts diff --git a/README.md b/README.md index 9da2003..dc28ee0 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,12 @@ commit setup You will be prompted to choose: -- **AI provider**: Google Gemini, OpenAI, or Anthropic +- **AI provider**: Google Gemini, OpenAI, Anthropic, or xAI - **Auth method**: - Google Gemini: Google OAuth or API key - OpenAI: Sign in with ChatGPT or API key - Anthropic: Claude setup-token or API key + - xAI: API key - **Commit convention**: Conventional, Imperative, or Custom If you want to use your claude.ai subscription with Anthropic, run `claude setup-token` in another terminal first, then paste the generated setup-token during `commit setup`. @@ -212,6 +213,7 @@ commit --help - **Google Gemini** — Google OAuth or API key - **OpenAI** — Sign in with your ChatGPT Plus/Pro subscription or API key - **Anthropic** (Claude) — Claude setup-token (`claude setup-token`) or API key +- **xAI** (Grok) — API key from [console.x.ai](https://console.x.ai) Contributions and feedback are welcome! diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index a39d5fd..14ba3bd 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -210,6 +210,8 @@ function authMethodDescription(ai: ProviderConfig): string { return "Anthropic API Key"; case "gemini": return "Google AI Studio API Key"; + case "xai": + return "xAI API Key"; } default: return "This should never happen. Please run 'commit-tools setup' to create a new configuration."; diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 4ca6a62..468c361 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -36,7 +36,8 @@ class Setup { options: [ { value: "gemini", label: "Google" }, { value: "openai", label: "OpenAI" }, - { value: "anthropic", label: "Anthropic" } + { value: "anthropic", label: "Anthropic" }, + { value: "xai", label: "xAI" } ], initialValue: "gemini" as const }); @@ -225,6 +226,14 @@ function getAuthMethodOptions(provider: ProviderConfig["provider"]): Option ({ id: m.id, description: m.display_name ?? "", openaiEffort: Nothing() })); }); +const fetchXaiModelsWith = (options: ClientOptions): Future => + Future.attemptP(async () => { + const list = await new OpenAI(options).models.list(); + const models: Array<{ id: string }> = []; + for await (const model of list) { + models.push(model); + } + return models.sort((a, b) => a.id.localeCompare(b.id)).map((m) => ({ id: m.id, description: "", openaiEffort: Nothing() })); + }).mapRej((error) => new Error(`Failed to fetch xAI models: ${error instanceof Error ? error.message : String(error)}`)); + +const fetchXaiModels = (authMethod: ProviderConfig["auth_method"]): Future => { + switch (authMethod.type) { + case "api_key": + return fetchXaiModelsWith(xaiApiKeyOptions(authMethod.content)); + case "google_oauth": + case "openai_oauth": + case "anthropic_setup_token": + return unsupportedAuth("xai", authMethod.type); + default: + return absurd(authMethod, "AuthMethod"); + } +}; + const fetchModels = (provider: ProviderConfig["provider"], authMethod: ProviderConfig["auth_method"]): Future => { switch (provider) { case "openai": @@ -140,5 +166,7 @@ const fetchModels = (provider: ProviderConfig["provider"], authMethod: ProviderC return fetchGeminiModels(authMethod); case "anthropic": return fetchAnthropicModels(authMethod); + case "xai": + return fetchXaiModels(authMethod); } }; diff --git a/src/domain/config/config.ts b/src/domain/config/config.ts index fc8bfe6..79be4ce 100644 --- a/src/domain/config/config.ts +++ b/src/domain/config/config.ts @@ -7,6 +7,7 @@ export { type ProviderConfig, type OpenAIEffort, type OpenAIModelEffort, + type XaiEffort, type AnthropicEffort, type GeminiEffort, type Model, @@ -18,6 +19,7 @@ export { resolveAuthMethod, COMMIT_CONVENTIONS, OPENAI_EFFORTS, + XAI_EFFORTS, ANTHROPIC_EFFORTS, GEMINI_EFFORTS }; @@ -73,6 +75,7 @@ const schema_AuthMethod = s.discriminatedUnion([ type AuthMethod = s.Infer["type"]; const OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly NonNullable[]; +const XAI_EFFORTS = ["low", "high"] as const satisfies readonly NonNullable[]; const ANTHROPIC_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const satisfies readonly NonNullable[]; const GEMINI_EFFORTS = [ThinkingLevel.MINIMAL, ThinkingLevel.LOW, ThinkingLevel.MEDIUM, ThinkingLevel.HIGH] as const satisfies readonly ThinkingLevel[]; @@ -81,6 +84,7 @@ type OpenAIModelEffort = { readonly options: readonly [OpenAIEffort, ...OpenAIEffort[]]; readonly defaultValue: OpenAIEffort; }; +type XaiEffort = (typeof XAI_EFFORTS)[number]; type AnthropicEffort = (typeof ANTHROPIC_EFFORTS)[number]; type GeminiEffort = (typeof GEMINI_EFFORTS)[number]; @@ -102,6 +106,12 @@ const schema_ProviderConfig = s.discriminatedUnion([ model: s.string, auth_method: schema_AuthMethod, effort: s.optionalMaybe(s.stringEnum([...ANTHROPIC_EFFORTS])) + }), + s.variant({ + provider: "xai", + model: s.string, + auth_method: schema_AuthMethod, + effort: s.optionalMaybe(s.stringEnum([...XAI_EFFORTS])) }) ]); type ProviderConfig = s.Infer; @@ -114,6 +124,8 @@ const resolveAuthMethod = (ai: ProviderConfig, auth_method: ProviderConfig["auth return { provider: "anthropic", model: ai.model, auth_method, effort: ai.effort }; case "gemini": return { provider: "gemini", model: ai.model, auth_method, effort: ai.effort }; + case "xai": + return { provider: "xai", model: ai.model, auth_method, effort: ai.effort }; default: return absurd(ai, "ProviderConfig"); } diff --git a/src/domain/llm/effort.ts b/src/domain/llm/effort.ts index abe86b4..cf82871 100644 --- a/src/domain/llm/effort.ts +++ b/src/domain/llm/effort.ts @@ -1,9 +1,16 @@ export { seedProviderConfig, withModel, selectEffortForProvider }; import { type Future } from "@/libs/future"; -import { type ProviderConfig, type OpenAIEffort, type OpenAIModelEffort, type AnthropicEffort, type GeminiEffort } from "@/domain/config/config"; +import { + type ProviderConfig, + type OpenAIEffort, + type OpenAIModelEffort, + type XaiEffort, + type AnthropicEffort, + type GeminiEffort +} from "@/domain/config/config"; import { Nothing, type Maybe } from "@/libs/maybe"; -import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort } from "@/infra/ui/effort-picker"; +import { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort, selectXaiEffort } from "@/infra/ui/effort-picker"; import { absurd } from "@/libs/types"; const seedProviderConfig = (provider: ProviderConfig["provider"], model: string, auth_method: ProviderConfig["auth_method"]): ProviderConfig => { @@ -14,6 +21,8 @@ const seedProviderConfig = (provider: ProviderConfig["provider"], model: string, return { provider, model, auth_method, effort: Nothing() }; case "gemini": return { provider, model, auth_method, effort: Nothing() }; + case "xai": + return { provider, model, auth_method, effort: Nothing() }; default: return absurd(provider, "provider"); } @@ -27,6 +36,8 @@ const withModel = (ai: ProviderConfig, model: string): ProviderConfig => { return { provider: "anthropic", model, auth_method: ai.auth_method, effort: ai.effort }; case "gemini": return { provider: "gemini", model, auth_method: ai.auth_method, effort: ai.effort }; + case "xai": + return { provider: "xai", model, auth_method: ai.auth_method, effort: ai.effort }; default: return absurd(ai, "ProviderConfig"); } @@ -61,6 +72,15 @@ const selectEffortForProvider = (current: ProviderConfig, modelEffort: Maybe ({ + provider: "xai", + model: current.model, + auth_method: current.auth_method, + effort + }) + ); default: return absurd(current, "ProviderConfig"); } diff --git a/src/domain/llm/router.ts b/src/domain/llm/router.ts index 6074b56..0bbafb2 100644 --- a/src/domain/llm/router.ts +++ b/src/domain/llm/router.ts @@ -18,6 +18,7 @@ import { type ProviderConfig, type CommitConvention } from "@/domain/config/conf import { generateContentWithGemini } from "@/infra/llm/gemini"; import { generateContentWithOpenAI } from "@/infra/llm/openai"; import { generateContentWithAnthropic } from "@/infra/llm/anthropic"; +import { generateContentWithXai } from "@/infra/llm/xai"; import { getPrompt, getRefinePrompt, getBranchNamePrompt } from "@/domain/commit/prompts"; import { parseAndValidateBranchSuggestions, type BranchSuggestion } from "@/domain/branch/suggestions"; import { withTransientRetry } from "@/domain/llm/retry"; @@ -65,6 +66,7 @@ type ProviderGeneratedContent = { const modelRequestMetadata = (config: ProviderConfig, effectiveEffort: Maybe): ModelRequestMetadata => { switch (config.provider) { case "openai": + case "xai": return { provider: config.provider, model: config.model, @@ -100,6 +102,8 @@ const generateContent = (config: ProviderConfig, params: GenerateContentParams): return withRequestMetadata(config, generateContentWithOpenAI(config, params)); case "anthropic": return withRequestMetadata(config, generateContentWithAnthropic(config, params)); + case "xai": + return withRequestMetadata(config, generateContentWithXai(config, params)); } }; diff --git a/src/infra/auth/xai.ts b/src/infra/auth/xai.ts new file mode 100644 index 0000000..7cdb099 --- /dev/null +++ b/src/infra/auth/xai.ts @@ -0,0 +1,8 @@ +export { xaiApiKeyOptions, XAI_API_BASE_URL }; + +import type { ClientOptions } from "openai"; + +const XAI_API_BASE_URL = "https://api.x.ai/v1"; + +/** xAI's API is OpenAI-compatible, so the `openai` client serves it with only a `baseURL` change. */ +const xaiApiKeyOptions = (apiKey: string): ClientOptions => ({ baseURL: XAI_API_BASE_URL, apiKey, maxRetries: 3, timeout: 120_000 }); diff --git a/src/infra/llm/xai.ts b/src/infra/llm/xai.ts new file mode 100644 index 0000000..df10e8e --- /dev/null +++ b/src/infra/llm/xai.ts @@ -0,0 +1,78 @@ +export { generateContentWithXai }; + +import OpenAI, { type ClientOptions } from "openai"; + +import { type Config, type XaiEffort } from "@/domain/config/config"; +import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; +import { Future } from "@/libs/future"; +import { xaiApiKeyOptions } from "@/infra/auth/xai"; +import { extractResponse } from "@/domain/llm/response-parser"; +import { unsupportedAuth } from "@/domain/llm/auth-error"; +import { absurd } from "@/libs/types"; +import { Just, Nothing, fromOptional, type Maybe } from "@/libs/maybe"; + +type XaiConfig = Extract; +type Attempt = { readonly completion: OpenAI.Chat.ChatCompletion; readonly attemptedEffort: Maybe }; + +const toTokenUsage = (usage: OpenAI.CompletionUsage): TokenUsage => ({ + input: Just(usage.prompt_tokens), + output: Just(usage.completion_tokens), + total: Just(usage.total_tokens) +}); + +const buildMessages = (params: GenerateContentParams): OpenAI.Chat.ChatCompletionMessageParam[] => + fromOptional(params.systemInstruction).maybe([{ role: "user", content: params.prompt }], (instruction) => [ + { role: "system", content: instruction }, + { role: "user", content: params.prompt } + ]); + +const buildParams = (model: string, effort: Maybe, params: GenerateContentParams): OpenAI.Chat.ChatCompletionCreateParamsNonStreaming => { + const core: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = { model, messages: buildMessages(params) }; + return effort.maybe(core, (reasoning_effort) => ({ ...core, reasoning_effort })); +}; + +/** Grok accepts `reasoning_effort` on some models and rejects it on the rest, and `/v1/models` does not say which. */ +const isUnsupportedEffort = (error: unknown): boolean => error instanceof OpenAI.BadRequestError && /reasoning_effort/i.test(error.message); + +const requestCompletion = async (client: OpenAI, model: string, effort: Maybe, params: GenerateContentParams): Promise => { + const attempt = async (attemptedEffort: Maybe): Promise => ({ + completion: await client.chat.completions.create(buildParams(model, attemptedEffort, params)), + attemptedEffort + }); + + try { + return await attempt(effort); + } catch (error) { + if (effort instanceof Nothing || !isUnsupportedEffort(error)) throw error; + return await attempt(Nothing()); + } +}; + +const callXai = ( + options: ClientOptions, + model: string, + effort: Maybe, + params: GenerateContentParams +): Future => + Future.attemptP(() => requestCompletion(new OpenAI(options), model, effort, params)) + .mapRej((error) => new Error(`Failed to create xAI completion: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + .chain(({ completion, attemptedEffort }) => + extractResponse({ text: fromOptional(completion.choices[0]?.message?.content ?? undefined) }).map((text) => ({ + text, + tokens: fromOptional(completion.usage).map(toTokenUsage), + effectiveEffort: Just(attemptedEffort.maybe("provider default", (value) => value)) + })) + ); + +const generateContentWithXai = (config: XaiConfig, params: GenerateContentParams): Future => { + switch (config.auth_method.type) { + case "api_key": + return callXai(xaiApiKeyOptions(config.auth_method.content), config.model, config.effort, params); + case "google_oauth": + case "openai_oauth": + case "anthropic_setup_token": + return unsupportedAuth("xai", config.auth_method.type); + default: + return absurd(config.auth_method, "AuthMethod"); + } +}; diff --git a/src/infra/ui/effort-picker.ts b/src/infra/ui/effort-picker.ts index 34b8d63..3d0a1a1 100644 --- a/src/infra/ui/effort-picker.ts +++ b/src/infra/ui/effort-picker.ts @@ -1,4 +1,4 @@ -export { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort }; +export { selectOpenAIEffort, selectAnthropicEffort, selectGeminiEffort, selectXaiEffort }; import { ThinkingLevel } from "@google/genai"; @@ -8,10 +8,12 @@ import { OPENAI_EFFORTS, ANTHROPIC_EFFORTS, GEMINI_EFFORTS, + XAI_EFFORTS, type OpenAIEffort, type OpenAIModelEffort, type AnthropicEffort, - type GeminiEffort + type GeminiEffort, + type XaiEffort } from "@/domain/config/config"; type EffortSliderModule = typeof import("@/infra/ui/effort-slider"); @@ -69,3 +71,6 @@ const selectAnthropicEffort = (modelId: string, current: Maybe) const selectGeminiEffort = (modelId: string, current: Maybe): Future> => selectEffort(GEMINI_EFFORTS, modelId, current, ThinkingLevel.MEDIUM); + +const selectXaiEffort = (modelId: string, current: Maybe): Future> => + selectEffort(XAI_EFFORTS, modelId, current, "low"); diff --git a/test/cli/setup.test.ts b/test/cli/setup.test.ts index 49c5057..ac746c3 100644 --- a/test/cli/setup.test.ts +++ b/test/cli/setup.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@/infra/env", () => ({ environment: { GOOGLE_CLIENT_ID: "test", GOOGLE_CLIENT_SECRET: "test" } @@ -19,7 +19,7 @@ import { runFuture } from "@test/helpers/run-future"; vi.mock("@clack/prompts", () => ({ intro: vi.fn(), outro: vi.fn(), - select: vi.fn().mockResolvedValueOnce("openai").mockResolvedValueOnce("conventional").mockResolvedValueOnce("api_key"), + select: vi.fn(), confirm: vi.fn(async () => true), text: vi.fn(async () => "sk-test"), password: vi.fn(async () => "sk-test"), @@ -47,7 +47,8 @@ vi.mock("@/infra/ui/model-picker", () => ({ ) })); vi.mock("@/infra/ui/effort-picker", () => ({ - selectOpenAIEffort: vi.fn(() => Future.resolve(Just("medium" as const))) + selectOpenAIEffort: vi.fn(() => Future.resolve(Just("medium" as const))), + selectXaiEffort: vi.fn(() => Future.resolve(Just("low" as const))) })); vi.mock("@/infra/ui/spinner", () => ({ loading: vi.fn((_a: string, _b: string, f: Future) => f as Future), @@ -61,10 +62,33 @@ vi.mock("@/infra/auth/anthropic", () => ({ validateAnthropicSetupToken: vi.fn() })); +/** The wizard asks provider, then convention, then auth method — in that order. */ +const scriptWizard = async (provider: string, convention: string, authMethod: string) => { + const p = await import("@clack/prompts"); + vi.mocked(p.select).mockReset(); + vi.mocked(p.select).mockResolvedValueOnce(provider).mockResolvedValueOnce(convention).mockResolvedValueOnce(authMethod); +}; + describe("Setup.run", () => { + beforeEach(() => vi.clearAllMocks()); + it("saves config after wizard", async () => { + await scriptWizard("openai", "conventional", "api_key"); const { saveConfig } = await import("@/infra/storage/config"); + await runFuture(Setup.create().chain((s) => s.run())); + expect(saveConfig).toHaveBeenCalled(); }); + + it("saves an xai api_key config", async () => { + await scriptWizard("xai", "conventional", "api_key"); + const { saveConfig } = await import("@/infra/storage/config"); + + await runFuture(Setup.create().chain((s) => s.run())); + + expect(saveConfig).toHaveBeenCalledWith( + expect.objectContaining({ ai: expect.objectContaining({ provider: "xai", auth_method: { type: "api_key", content: "sk-test" } }) }) + ); + }); }); diff --git a/test/domain/commit/models.test.ts b/test/domain/commit/models.test.ts index 3b772cc..544ad6c 100644 --- a/test/domain/commit/models.test.ts +++ b/test/domain/commit/models.test.ts @@ -3,12 +3,36 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { fetchModels } from "@/domain/commit/models"; import { runFuture } from "@test/helpers/run-future"; +const list = vi.hoisted(() => vi.fn()); +const constructed = vi.hoisted(() => [] as unknown[]); + +vi.mock("openai", () => { + class MockOpenAI { + readonly models = { list }; + constructor(options: unknown) { + constructed.push(options); + } + } + + return { default: MockOpenAI }; +}); + const openAIOAuth = { type: "openai_oauth" as const, content: { access_token: "access", refresh_token: "refresh", expiry_date: 0 } }; -afterEach(() => vi.unstubAllGlobals()); +const asyncPageOf = (ids: string[]) => ({ + async *[Symbol.asyncIterator]() { + for (const id of ids) yield { id }; + } +}); + +afterEach(() => { + vi.unstubAllGlobals(); + list.mockReset(); + constructed.length = 0; +}); describe("fetchModels", () => { it("preserves supported OpenAI efforts from the Codex catalog", async () => { @@ -48,4 +72,17 @@ describe("fetchModels", () => { defaultValue: "low" }); }); + + it("lists xAI models sorted by id against the xAI base URL", async () => { + list.mockResolvedValue(asyncPageOf(["grok-4.5", "grok-3"])); + + const models = await runFuture(fetchModels("xai", { type: "api_key", content: "xai-test" })); + + expect(constructed[0]).toMatchObject({ baseURL: "https://api.x.ai/v1", apiKey: "xai-test" }); + expect(models.map((m) => m.id)).toEqual(["grok-3", "grok-4.5"]); + }); + + it("rejects an auth method xAI does not support", async () => { + await expect(runFuture(fetchModels("xai", openAIOAuth))).rejects.toThrow("Unsupported auth method for xai"); + }); }); diff --git a/test/domain/config/config.test.ts b/test/domain/config/config.test.ts index bd45762..844f161 100644 --- a/test/domain/config/config.test.ts +++ b/test/domain/config/config.test.ts @@ -31,6 +31,30 @@ describe("Config schema", () => { } }); + it("round-trips xai api_key config with an effort", () => { + const config: ConfigValue = { + ...sampleConfig(), + ai: { provider: "xai", model: "grok-4.5", effort: Just("high" as const), auth_method: { type: "api_key", content: "xai-test" } } + }; + + const decoded = s.decode(Config, s.encode(Config, config)); + + expect(decoded.isSuccess()).toBe(true); + if (!(decoded instanceof Success)) return; + expect(decoded.value.ai.provider).toBe("xai"); + expect(decoded.value.ai.effort).toBeInstanceOf(Just); + }); + + it("rejects an effort xAI does not support", () => { + const encoded = s.encode(Config, { + ...sampleConfig(), + ai: { provider: "xai", model: "grok-4.5", effort: Just("high" as const), auth_method: { type: "api_key", content: "xai-test" } } + }) as Record; + const bad = { ...encoded, ai: { ...(encoded["ai"] as Record), effort: "xhigh" } }; + + expect(s.decode(Config, bad).isFailure()).toBe(true); + }); + it("rejects invalid provider", () => { const bad = { ...sampleConfig(), ai: { provider: "unknown" } }; expect(s.decode(Config, bad).isFailure()).toBe(true); diff --git a/test/domain/llm/router.test.ts b/test/domain/llm/router.test.ts index 0028d44..a004326 100644 --- a/test/domain/llm/router.test.ts +++ b/test/domain/llm/router.test.ts @@ -22,11 +22,14 @@ vi.mock("@/infra/llm/openai", () => ({ vi.mock("@/infra/llm/anthropic", () => ({ generateContentWithAnthropic: vi.fn(() => Future.resolve({ text: "feat: test", tokens: Nothing(), effectiveEffort: Nothing() })) })); +vi.mock("@/infra/llm/xai", () => ({ + generateContentWithXai: vi.fn(() => Future.resolve({ text: "feat: test", tokens: Nothing(), effectiveEffort: Nothing() })) +})); describe("generateCommitMessage", () => { beforeEach(() => vi.clearAllMocks()); - it.each(["gemini", "openai", "anthropic"] as const)("routes to %s provider", async (provider) => { + it.each(["gemini", "openai", "anthropic", "xai"] as const)("routes to %s provider", async (provider) => { const result = await runFuture(generateCommitMessage(mockProvider(provider), "diff", "conventional", Nothing())); expect(result.text).toBe("feat: test"); expect(result.metadata.model.provider).toBe(provider); diff --git a/test/infra/llm/xai.test.ts b/test/infra/llm/xai.test.ts new file mode 100644 index 0000000..e840a96 --- /dev/null +++ b/test/infra/llm/xai.test.ts @@ -0,0 +1,168 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import OpenAI from "openai"; + +import { type Config, type XaiEffort } from "@/domain/config/config"; +import { generateContentWithXai } from "@/infra/llm/xai"; +import { Just, Nothing } from "@/libs/maybe"; +import { runFuture } from "@test/helpers/run-future"; + +const create = vi.hoisted(() => vi.fn()); +const constructed = vi.hoisted(() => [] as unknown[]); + +vi.mock("openai", async (importOriginal) => { + const actual = await importOriginal(); + + class MockOpenAI { + static BadRequestError = actual.default.BadRequestError; + readonly chat = { completions: { create } }; + constructor(options: unknown) { + constructed.push(options); + } + } + + return { default: MockOpenAI }; +}); + +type XaiConfig = Extract; + +const configWith = (effort: XaiConfig["effort"]): XaiConfig => ({ + provider: "xai", + model: "grok-4.5", + effort, + auth_method: { type: "api_key", content: "xai-test" } +}); + +const completion = { + choices: [{ message: { content: "Add retry to the token refresh" } }], + usage: { prompt_tokens: 1, completion_tokens: 2, total_tokens: 3 } +}; + +const unsupportedEffortError = () => + new OpenAI.BadRequestError( + 400, + { code: "invalid_request_error", message: "reasoning_effort is not supported for model grok-4.5" }, + undefined, + new Headers() + ); + +describe("generateContentWithXai", () => { + beforeEach(() => { + create.mockReset(); + constructed.length = 0; + }); + + it("targets the xAI API base URL with the configured key", async () => { + create.mockResolvedValue(completion); + + await runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" })); + + expect(constructed[0]).toMatchObject({ baseURL: "https://api.x.ai/v1", apiKey: "xai-test" }); + }); + + it("sends a configured effort once and reports it", async () => { + create.mockResolvedValue(completion); + + const result = await runFuture(generateContentWithXai(configWith(Just("high")), { prompt: "diff" })); + + expect(create).toHaveBeenCalledTimes(1); + expect(create.mock.calls[0]?.[0]).toMatchObject({ model: "grok-4.5", reasoning_effort: "high" }); + expect(result.text).toBe("Add retry to the token refresh"); + expect(result.effectiveEffort.expect("Expected effective effort")).toBe("high"); + }); + + it("omits reasoning_effort when no effort is configured", async () => { + create.mockResolvedValue(completion); + + const result = await runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" })); + + expect(create.mock.calls[0]?.[0]).not.toHaveProperty("reasoning_effort"); + expect(result.effectiveEffort.expect("Expected effective effort")).toBe("provider default"); + }); + + it("retries once without reasoning_effort when the model rejects it", async () => { + create.mockRejectedValueOnce(unsupportedEffortError()).mockResolvedValueOnce(completion); + + const result = await runFuture(generateContentWithXai(configWith(Just("high")), { prompt: "diff" })); + + expect(create).toHaveBeenCalledTimes(2); + expect(create.mock.calls[0]?.[0]).toMatchObject({ reasoning_effort: "high" }); + expect(create.mock.calls[1]?.[0]).not.toHaveProperty("reasoning_effort"); + expect(result.effectiveEffort.expect("Expected effective effort")).toBe("provider default"); + }); + + it("does not retry when no effort was sent in the first place", async () => { + create.mockRejectedValue(unsupportedEffortError()); + + await expect(runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" }))).rejects.toThrow("Failed to create xAI completion"); + expect(create).toHaveBeenCalledTimes(1); + }); + + it("does not retry unrelated bad requests", async () => { + const error = new OpenAI.BadRequestError(400, { code: "invalid_request_error", message: "messages must not be empty" }, undefined, new Headers()); + create.mockRejectedValue(error); + + await expect(runFuture(generateContentWithXai(configWith(Just("high")), { prompt: "diff" }))).rejects.toThrow( + "Failed to create xAI completion" + ); + expect(create).toHaveBeenCalledTimes(1); + }); + + it("prepends the system instruction as a system message", async () => { + create.mockResolvedValue(completion); + + await runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff", systemInstruction: "be terse" })); + + expect(create.mock.calls[0]?.[0]).toMatchObject({ + messages: [ + { role: "system", content: "be terse" }, + { role: "user", content: "diff" } + ] + }); + }); + + it("sends only the user message when there is no system instruction", async () => { + create.mockResolvedValue(completion); + + await runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" })); + + expect(create.mock.calls[0]?.[0]).toMatchObject({ messages: [{ role: "user", content: "diff" }] }); + }); + + it("maps usage to token counts", async () => { + create.mockResolvedValue(completion); + + const result = await runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" })); + + const tokens = result.tokens.expect("Expected token usage"); + expect(tokens.input.expect("input")).toBe(1); + expect(tokens.output.expect("output")).toBe(2); + expect(tokens.total.expect("total")).toBe(3); + }); + + it("reports Nothing for tokens when usage is absent", async () => { + create.mockResolvedValue({ choices: completion.choices }); + + const result = await runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" })); + + expect(result.tokens).toBeInstanceOf(Nothing); + }); + + it("rejects an empty completion", async () => { + create.mockResolvedValue({ choices: [{ message: { content: " " } }] }); + + await expect(runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" }))).rejects.toThrow("Response text is empty or missing"); + }); + + it("rejects an auth method xAI does not support", async () => { + const config = { + ...configWith(Nothing()), + auth_method: { + type: "google_oauth" as const, + content: { access_token: "a", refresh_token: "r", expiry_date: 1, token_type: "Bearer", scope: "openid" } + } + }; + + await expect(runFuture(generateContentWithXai(config, { prompt: "diff" }))).rejects.toThrow("Unsupported auth method for xai"); + }); +}); From 6944defd3e26db41bd649807552893a1ec12bff2 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 1 Aug 2026 22:05:06 -0300 Subject: [PATCH 3/5] Add Grok subscription sign-in for xAI - Add the `xai_oauth` auth variant carrying `schema_BearerTokens`, so a SuperGrok or X Premium subscription can drive the provider instead of a metered API key. - Add the authorization-code + PKCE S256 flow in `src/infra/auth/xai.ts` against `https://auth.x.ai`, with endpoints read once from the OIDC discovery document and hardcoded, since refresh runs on every command and discovery would add a round-trip to each one. - Bind the loopback callback server on an OS-assigned port and read the bound port back, rather than reserving a fixed one; the redirect URI is therefore built inside the bracket, once the server is listening. - Point OAuth requests at `https://cli-chat-proxy.grok.com/v1` with `X-XAI-Token-Auth: xai-grok-cli`, which is what marks the bearer as a user token rather than a deployment key. - Set `maxRetries: 0` on the proxy client so a 429 against a metered subscription is not silently retried three times; `withTransientRetry` already owns retry policy. - Extract `generateCodeVerifier`, `generateCodeChallenge`, `generateState`, `stopCallbackServer`, `openBrowser`, and `oauthTimeout` into `src/infra/auth/oauth.ts`, shared by the OpenAI and xAI flows. - Refresh and persist xAI tokens through `resolveProvider`, and report their expiry in `commit doctor`. - Offer "Sign in with Grok" as the default xAI auth method in `commit setup`. - Add `xai_oauth` arms to the auth switches in the Gemini, OpenAI, and Anthropic clients and in `fetchModels`. - Cover the authorize URL, PKCE derivation, refresh buffer, token rotation, and revoked-token message with tests, plus the proxy client options for chat and model listing. - Document the subscription sign-in in `README.md`. --- README.md | 4 +- src/cli/doctor.ts | 4 + src/cli/setup.ts | 18 ++- src/domain/commit/models.ts | 4 +- src/domain/config/config.ts | 4 + src/domain/llm/auth-resolver.ts | 6 + src/infra/auth/oauth.ts | 41 ++++++ src/infra/auth/openai.ts | 48 ++----- src/infra/auth/xai.ts | 201 +++++++++++++++++++++++++++++- src/infra/llm/anthropic.ts | 1 + src/infra/llm/gemini.ts | 1 + src/infra/llm/openai.ts | 1 + src/infra/llm/xai.ts | 6 +- test/domain/commit/models.test.ts | 8 ++ test/infra/auth/xai.test.ts | 145 +++++++++++++++++++++ test/infra/llm/xai.test.ts | 17 +++ 16 files changed, 467 insertions(+), 42 deletions(-) create mode 100644 src/infra/auth/oauth.ts create mode 100644 test/infra/auth/xai.test.ts diff --git a/README.md b/README.md index dc28ee0..9d67899 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ You will be prompted to choose: - Google Gemini: Google OAuth or API key - OpenAI: Sign in with ChatGPT or API key - Anthropic: Claude setup-token or API key - - xAI: API key + - xAI: Sign in with Grok or API key - **Commit convention**: Conventional, Imperative, or Custom If you want to use your claude.ai subscription with Anthropic, run `claude setup-token` in another terminal first, then paste the generated setup-token during `commit setup`. @@ -213,7 +213,7 @@ commit --help - **Google Gemini** — Google OAuth or API key - **OpenAI** — Sign in with your ChatGPT Plus/Pro subscription or API key - **Anthropic** (Claude) — Claude setup-token (`claude setup-token`) or API key -- **xAI** (Grok) — API key from [console.x.ai](https://console.x.ai) +- **xAI** (Grok) — Sign in with your SuperGrok/X Premium subscription or API key Contributions and feedback are welcome! diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 14ba3bd..d215bdb 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -171,6 +171,7 @@ function tokenExpiry(authMethod: ProviderConfig["auth_method"]): Maybe { switch (authMethod.type) { case "google_oauth": case "openai_oauth": + case "xai_oauth": return Just(authMethod.content.expiry_date); case "api_key": case "anthropic_setup_token": @@ -184,6 +185,7 @@ function authMethodLabel(authMethod: AuthMethod): string { switch (authMethod) { case "google_oauth": case "openai_oauth": + case "xai_oauth": return "OAuth"; case "anthropic_setup_token": return "Setup Token"; @@ -200,6 +202,8 @@ function authMethodDescription(ai: ProviderConfig): string { return "Google OAuth 2.0"; case "openai_oauth": return "OpenAI Codex OAuth"; + case "xai_oauth": + return "Grok Subscription OAuth"; case "anthropic_setup_token": return "Claude Setup-Token"; case "api_key": diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 468c361..4473421 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -8,6 +8,7 @@ import { saveConfig } from "@/infra/storage/config"; import { CommitConvention, type Config, type Model, type ProviderConfig } from "@/domain/config/config"; import { performOAuthFlow, type GoogleOAuthPhase } from "@/infra/auth/google"; import { performOpenAIOAuthFlow, validateOpenAITokens } from "@/infra/auth/openai"; +import { performXaiOAuthFlow } from "@/infra/auth/xai"; import { validateAnthropicApiKey, validateAnthropicSetupToken } from "@/infra/auth/anthropic"; import { Just, Nothing } from "@/libs/maybe"; import { bracketStatus, loading } from "@/infra/ui/spinner"; @@ -21,7 +22,7 @@ type SetupPreferences = { readonly convention: CommitConvention; readonly customTemplate: string | undefined; readonly provider: ProviderConfig["provider"]; - readonly authMethod: "google_oauth" | "openai_oauth" | "api_key" | "anthropic_setup_token"; + readonly authMethod: "google_oauth" | "openai_oauth" | "xai_oauth" | "api_key" | "anthropic_setup_token"; }; class Setup { @@ -89,6 +90,8 @@ class Setup { return this.setupOAuth(); case "openai_oauth": return this.setupOpenAIOAuth(); + case "xai_oauth": + return this.setupXaiOAuth(); case "anthropic_setup_token": return this.setupAnthropicSetupToken(); case "api_key": @@ -143,6 +146,12 @@ class Setup { .chain((authMethod) => this.finalizeSetup(authMethod)); } + private setupXaiOAuth(): Future { + p.log.info("Opening browser for Grok sign-in..."); + + return performXaiOAuthFlow().chain((tokens) => this.finalizeSetup({ type: "xai_oauth" as const, content: tokens })); + } + private setupApiKey(): Future { const { message, validate } = apiKeyPromptFor(this.preferences.provider); return Future.attemptP(async () => { @@ -228,6 +237,11 @@ function getAuthMethodOptions(provider: ProviderConfig["provider"]): Option["type"]; diff --git a/src/domain/llm/auth-resolver.ts b/src/domain/llm/auth-resolver.ts index a932459..6d999ab 100644 --- a/src/domain/llm/auth-resolver.ts +++ b/src/domain/llm/auth-resolver.ts @@ -5,6 +5,7 @@ import { Just, Nothing, type Maybe } from "@/libs/maybe"; import { resolveAuthMethod, type Config, type ProviderConfig, type RefreshTokens } from "@/domain/config/config"; import { ensureFreshTokens } from "@/infra/auth/google"; import { ensureFreshOpenAITokens } from "@/infra/auth/openai"; +import { ensureFreshXaiTokens } from "@/infra/auth/xai"; import { updateOAuthTokens } from "@/infra/storage/config"; import { absurd } from "@/libs/types"; @@ -44,6 +45,11 @@ const resolveProvider: ResolveProvider = (config) => { (tokens) => resolveAuthMethod(ai, { type: "openai_oauth", content: tokens }) ); + case "xai_oauth": + return refreshAndPersist(ai.auth_method.content, ensureFreshXaiTokens, (content) => updateOAuthTokens({ type: "xai_oauth", content })).map( + (tokens) => resolveAuthMethod(ai, { type: "xai_oauth", content: tokens }) + ); + default: return absurd(ai.auth_method, "AuthMethod"); } diff --git a/src/infra/auth/oauth.ts b/src/infra/auth/oauth.ts new file mode 100644 index 0000000..ca3edd3 --- /dev/null +++ b/src/infra/auth/oauth.ts @@ -0,0 +1,41 @@ +export { type CallbackServer, generateCodeVerifier, generateCodeChallenge, generateState, stopCallbackServer, openBrowser, oauthTimeout }; + +import { Future } from "@/libs/future"; +import { randomBytes, createHash } from "node:crypto"; +import { type Server } from "node:http"; + +type CallbackServer = { + readonly server: Server; + readonly port: number; + readonly codePromise: Promise; +}; + +const generateCodeVerifier = (): string => randomBytes(32).toString("base64url"); + +const generateCodeChallenge = (verifier: string): string => createHash("sha256").update(verifier).digest("base64url"); + +const generateState = (): string => randomBytes(32).toString("base64url"); + +const stopCallbackServer = (cs: CallbackServer): Future => + Future.create((_, resolve) => { + cs.server.close(() => { + resolve(undefined); + }); + }); + +const openBrowser = (url: string): Future => + Future.attemptP(async () => { + const open = (await import("open")).default; + await open(url); + }).chainRej((_) => { + console.log("\nCould not open browser automatically."); + console.log(`Please open the following URL in your browser:\n${url}\n`); + return Future.resolve(undefined); + }); + +/** Races against the browser round-trip so an abandoned sign-in cannot hang the CLI forever. */ +const oauthTimeout = (ms: number, message: string): Future => + Future.create((reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + return () => clearTimeout(timer); + }); diff --git a/src/infra/auth/openai.ts b/src/infra/auth/openai.ts index 950374a..62d4abb 100644 --- a/src/infra/auth/openai.ts +++ b/src/infra/auth/openai.ts @@ -2,9 +2,17 @@ export { performOpenAIOAuthFlow, ensureFreshOpenAITokens, validateOpenAITokens, import { type BearerTokens } from "@/domain/config/config"; import { SUCCESS_HTML, ERROR_HTML } from "@/infra/auth/templates"; +import { + type CallbackServer, + generateCodeVerifier, + generateCodeChallenge, + generateState, + stopCallbackServer, + openBrowser, + oauthTimeout +} from "@/infra/auth/oauth"; import { Future } from "@/libs/future"; -import { randomBytes, createHash } from "node:crypto"; -import { createServer, type Server } from "node:http"; +import { createServer } from "node:http"; const OPENAI_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"; const OPENAI_ISSUER = "https://auth.openai.com"; @@ -16,16 +24,6 @@ const DEFAULT_PORT = 1455; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; -type CallbackServer = { - readonly server: Server; - readonly port: number; - readonly codePromise: Promise; -}; - -const generateCodeVerifier = (): string => randomBytes(32).toString("base64url"); - -const generateCodeChallenge = (verifier: string): string => createHash("sha256").update(verifier).digest("base64url"); - const findAvailablePort = (): Future => Future.create((reject, resolve) => { const testServer = createServer(); @@ -97,23 +95,6 @@ const startCallbackServer = (port: number, state: string): Future => - Future.create((_, resolve) => { - cs.server.close(() => { - resolve(undefined); - }); - }); - -const openBrowser = (url: string): Future => - Future.attemptP(async () => { - const open = (await import("open")).default; - await open(url); - }).chainRej((_) => { - console.log("\nCould not open browser automatically."); - console.log(`Please open the following URL in your browser:\n${url}\n`); - return Future.resolve(undefined); - }); - const exchangeCodeForTokens = (code: string, codeVerifier: string, redirectUri: string): Future => Future.attemptP(async () => { const body = new URLSearchParams({ @@ -157,7 +138,7 @@ const performOpenAIOAuthFlow = (): Future => const redirectUri = `http://localhost:${port}/auth/callback`; const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); - const state = randomBytes(32).toString("base64url"); + const state = generateState(); const authUrl = new URL(OPENAI_AUTH_URL); authUrl.searchParams.set("response_type", "code"); @@ -174,12 +155,9 @@ const performOpenAIOAuthFlow = (): Future => return Future.bracket(startCallbackServer(port, state), stopCallbackServer, (cs) => { const waitForCode: Future = openBrowser(authUrl.toString()).chain(() => Future.attemptP(() => cs.codePromise)); - const timeout: Future = Future.create((reject) => { - const timer = setTimeout(() => reject(new Error("OAuth flow timed out after 5 minutes. Please try again.")), OAUTH_TIMEOUT_MS); - return () => clearTimeout(timer); - }); + const timeout = oauthTimeout(OAUTH_TIMEOUT_MS, "OAuth flow timed out after 5 minutes. Please try again."); - return Future.race(waitForCode, timeout).chain((code) => exchangeCodeForTokens(code, codeVerifier, redirectUri)); + return Future.race(waitForCode, timeout).chain((code) => exchangeCodeForTokens(code, codeVerifier, redirectUri)); }); }); diff --git a/src/infra/auth/xai.ts b/src/infra/auth/xai.ts index 7cdb099..3a44a8a 100644 --- a/src/infra/auth/xai.ts +++ b/src/infra/auth/xai.ts @@ -1,8 +1,207 @@ -export { xaiApiKeyOptions, XAI_API_BASE_URL }; +export { + xaiApiKeyOptions, + xaiOAuthOptions, + performXaiOAuthFlow, + ensureFreshXaiTokens, + getXaiAccessToken, + buildXaiAuthUrl, + XAI_API_BASE_URL, + XAI_PROXY_BASE_URL +}; + +import { type BearerTokens } from "@/domain/config/config"; +import { SUCCESS_HTML, ERROR_HTML } from "@/infra/auth/templates"; +import { + type CallbackServer, + generateCodeVerifier, + generateCodeChallenge, + generateState, + stopCallbackServer, + openBrowser, + oauthTimeout +} from "@/infra/auth/oauth"; +import { Future } from "@/libs/future"; +import { createServer } from "node:http"; import type { ClientOptions } from "openai"; const XAI_API_BASE_URL = "https://api.x.ai/v1"; +const XAI_PROXY_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; + +// Read once from https://auth.x.ai/.well-known/openid-configuration (fetched 2026-08-01). +// Hardcoded on purpose: refresh runs on every command through `resolveProvider`, so live +// discovery would put a network round-trip in front of every commit generation. If xAI +// moves these it will rotate the client id and scopes too, which discovery cannot supply. +const XAI_AUTH_URL = "https://auth.x.ai/oauth2/authorize"; +const XAI_TOKEN_URL = "https://auth.x.ai/oauth2/token"; +const XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"; +const SCOPES = "openid profile email offline_access grok-cli:access api:access"; +const OAUTH_TIMEOUT_MS = 300_000; +const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; /** xAI's API is OpenAI-compatible, so the `openai` client serves it with only a `baseURL` change. */ const xaiApiKeyOptions = (apiKey: string): ClientOptions => ({ baseURL: XAI_API_BASE_URL, apiKey, maxRetries: 3, timeout: 120_000 }); + +const xaiOAuthOptions = (accessToken: string): ClientOptions => ({ + baseURL: XAI_PROXY_BASE_URL, + apiKey: accessToken, + // Tells the proxy the bearer is a user token rather than a deployment key. + defaultHeaders: { "X-XAI-Token-Auth": "xai-grok-cli" }, + // The proxy meters a subscription, so SDK-level retries would multiply quota burn on a + // 429. `withTransientRetry` already owns retry policy for every provider. + maxRetries: 0, + timeout: 120_000 +}); + +const buildXaiAuthUrl = (redirectUri: string, codeChallenge: string, state: string): string => { + const url = new URL(XAI_AUTH_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", XAI_CLIENT_ID); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("scope", SCOPES); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("state", state); + url.searchParams.set("referrer", "grok-build"); + return url.toString(); +}; + +/** + * Binds an OS-assigned loopback port, so the redirect URI is only known once the server is + * listening — which is why the auth URL is built inside the bracket rather than before it. + */ +const startCallbackServer = (state: string): Future => + Future.create((reject, resolve) => { + let resolveCode: (code: string) => void; + let rejectCode: (err: Error) => void; + + const codePromise = new Promise((res, rej) => { + resolveCode = res; + rejectCode = rej; + }); + + const server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + + if (url.pathname !== "/callback") { + res.writeHead(404); + res.end("Not found"); + return; + } + + const fail = (message: string): void => { + rejectCode(new Error(message)); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(ERROR_HTML(message)); + }; + + const error = url.searchParams.get("error"); + if (error) return fail(`OAuth error: ${url.searchParams.get("error_description") ?? error}`); + if (url.searchParams.get("state") !== state) return fail("CSRF state mismatch — possible attack"); + + const code = url.searchParams.get("code"); + if (!code) return fail("No authorization code received"); + + resolveCode(code); + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(SUCCESS_HTML); + }); + + server.once("error", (err) => { + reject(new Error(`Failed to start callback server: ${err}`)); + }); + + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address === null || typeof address === "string") { + reject(new Error("Callback server did not bind to a TCP port")); + return; + } + resolve({ server, port: address.port, codePromise }); + }); + }); + +const exchangeCodeForTokens = (code: string, codeVerifier: string, redirectUri: string): Future => + Future.attemptP(async () => { + const response = 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_CLIENT_ID, + code, + redirect_uri: redirectUri, + code_verifier: codeVerifier + }).toString() + }); + + if (!response.ok) { + throw new Error(`Token exchange failed (${response.status}): ${await response.text()}`); + } + + const data = (await response.json()) as { access_token: string; refresh_token: string; expires_in: number }; + + if (!data.access_token || !data.refresh_token) { + throw new Error("Incomplete token response from xAI. Missing access_token or refresh_token."); + } + + return { access_token: data.access_token, refresh_token: data.refresh_token, expiry_date: Date.now() + data.expires_in * 1000 }; + }).mapRej((e) => new Error(`Token exchange failed: ${e}`)); + +const performXaiOAuthFlow = (): Future => { + const codeVerifier = generateCodeVerifier(); + const state = generateState(); + + return Future.bracket(startCallbackServer(state), stopCallbackServer, (cs) => { + const redirectUri = `http://127.0.0.1:${cs.port}/callback`; + const authUrl = buildXaiAuthUrl(redirectUri, generateCodeChallenge(codeVerifier), state); + + const waitForCode: Future = openBrowser(authUrl).chain(() => Future.attemptP(() => cs.codePromise)); + const timeout = oauthTimeout(OAUTH_TIMEOUT_MS, "OAuth flow timed out after 5 minutes. Please try again."); + + return Future.race(waitForCode, timeout).chain((code) => exchangeCodeForTokens(code, codeVerifier, redirectUri)); + }); +}; + +const ensureFreshXaiTokens = (tokens: BearerTokens): Future => { + if (tokens.expiry_date > Date.now() + TOKEN_REFRESH_BUFFER_MS) { + return Future.resolve(tokens); + } + + return Future.attemptP(async () => { + const response = await fetch(XAI_TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: XAI_CLIENT_ID, + refresh_token: tokens.refresh_token + }).toString() + }); + + if (!response.ok) { + throw new Error(`Token refresh failed (${response.status}): ${await response.text()}`); + } + + const data = (await response.json()) as { access_token: string; refresh_token?: string; expires_in: number }; + + if (!data.access_token) { + throw new Error("Token refresh returned no access_token"); + } + + return { + access_token: data.access_token, + refresh_token: data.refresh_token ?? tokens.refresh_token, + expiry_date: Date.now() + data.expires_in * 1000 + }; + }).mapRej((err) => { + const message = String(err); + if (message.includes("invalid_grant")) { + return new Error("xAI tokens have been revoked. Please run 'commit-tools setup' to re-authenticate."); + } + return new Error(`xAI token refresh failed: ${message}`); + }); +}; + +const getXaiAccessToken = (tokens: BearerTokens): Future => + tokens.access_token ? Future.resolve(tokens.access_token) : Future.reject(new Error("No xAI access token provided")); diff --git a/src/infra/llm/anthropic.ts b/src/infra/llm/anthropic.ts index 698b5aa..ab1dfb3 100644 --- a/src/infra/llm/anthropic.ts +++ b/src/infra/llm/anthropic.ts @@ -106,6 +106,7 @@ const generateContentWithAnthropic = (config: AnthropicConfig, params: GenerateC return callAnthropicWithSetupToken(config.auth_method.content, config.model, config.effort, params); case "google_oauth": case "openai_oauth": + case "xai_oauth": return unsupportedAuth("anthropic", config.auth_method.type); default: return absurd(config.auth_method, "AuthMethod"); diff --git a/src/infra/llm/gemini.ts b/src/infra/llm/gemini.ts index 4ac565b..d936d4a 100644 --- a/src/infra/llm/gemini.ts +++ b/src/infra/llm/gemini.ts @@ -117,6 +117,7 @@ const generateContentWithGemini = (config: GeminiConfig, params: GenerateContent return generateContentWithOAuth(config.auth_method.content, config.model, config.effort, params); case "openai_oauth": case "anthropic_setup_token": + case "xai_oauth": return unsupportedAuth("gemini", config.auth_method.type); default: return absurd(config.auth_method, "AuthMethod"); diff --git a/src/infra/llm/openai.ts b/src/infra/llm/openai.ts index 132821a..4f65405 100644 --- a/src/infra/llm/openai.ts +++ b/src/infra/llm/openai.ts @@ -136,6 +136,7 @@ const generateContentWithOpenAI = (config: OpenAIConfig, params: GenerateContent ); case "google_oauth": case "anthropic_setup_token": + case "xai_oauth": return unsupportedAuth("openai", config.auth_method.type); default: return absurd(config.auth_method, "AuthMethod"); diff --git a/src/infra/llm/xai.ts b/src/infra/llm/xai.ts index df10e8e..ec9ff6c 100644 --- a/src/infra/llm/xai.ts +++ b/src/infra/llm/xai.ts @@ -5,7 +5,7 @@ import OpenAI, { type ClientOptions } from "openai"; import { type Config, type XaiEffort } from "@/domain/config/config"; import { type GenerateContentParams, type ProviderGeneratedContent, type TokenUsage } from "@/domain/llm/router"; import { Future } from "@/libs/future"; -import { xaiApiKeyOptions } from "@/infra/auth/xai"; +import { xaiApiKeyOptions, xaiOAuthOptions, getXaiAccessToken } from "@/infra/auth/xai"; import { extractResponse } from "@/domain/llm/response-parser"; import { unsupportedAuth } from "@/domain/llm/auth-error"; import { absurd } from "@/libs/types"; @@ -68,6 +68,10 @@ const generateContentWithXai = (config: XaiConfig, params: GenerateContentParams switch (config.auth_method.type) { case "api_key": return callXai(xaiApiKeyOptions(config.auth_method.content), config.model, config.effort, params); + case "xai_oauth": + return getXaiAccessToken(config.auth_method.content).chain((accessToken) => + callXai(xaiOAuthOptions(accessToken), config.model, config.effort, params) + ); case "google_oauth": case "openai_oauth": case "anthropic_setup_token": diff --git a/test/domain/commit/models.test.ts b/test/domain/commit/models.test.ts index 544ad6c..ed4f767 100644 --- a/test/domain/commit/models.test.ts +++ b/test/domain/commit/models.test.ts @@ -82,6 +82,14 @@ describe("fetchModels", () => { expect(models.map((m) => m.id)).toEqual(["grok-3", "grok-4.5"]); }); + it("lists xAI models over subscription OAuth through the CLI proxy", async () => { + list.mockResolvedValue(asyncPageOf(["grok-4.5"])); + + await runFuture(fetchModels("xai", { type: "xai_oauth", content: { access_token: "grok-access", refresh_token: "r", expiry_date: 1 } })); + + expect(constructed[0]).toMatchObject({ baseURL: "https://cli-chat-proxy.grok.com/v1", defaultHeaders: { "X-XAI-Token-Auth": "xai-grok-cli" } }); + }); + it("rejects an auth method xAI does not support", async () => { await expect(runFuture(fetchModels("xai", openAIOAuth))).rejects.toThrow("Unsupported auth method for xai"); }); diff --git a/test/infra/auth/xai.test.ts b/test/infra/auth/xai.test.ts new file mode 100644 index 0000000..733066f --- /dev/null +++ b/test/infra/auth/xai.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createHash } from "node:crypto"; + +import { buildXaiAuthUrl, ensureFreshXaiTokens, xaiApiKeyOptions, xaiOAuthOptions } from "@/infra/auth/xai"; +import { generateCodeChallenge, generateCodeVerifier } from "@/infra/auth/oauth"; +import { runFuture } from "@test/helpers/run-future"; + +afterEach(() => vi.unstubAllGlobals()); + +const tokens = (expiry_date: number) => ({ access_token: "old-access", refresh_token: "old-refresh", expiry_date }); + +describe("xaiApiKeyOptions", () => { + it("targets the public API and lets the SDK retry", () => { + expect(xaiApiKeyOptions("xai-test")).toMatchObject({ baseURL: "https://api.x.ai/v1", apiKey: "xai-test", maxRetries: 3 }); + }); +}); + +describe("xaiOAuthOptions", () => { + it("targets the CLI proxy and flags the bearer as a user token", () => { + expect(xaiOAuthOptions("access")).toMatchObject({ + baseURL: "https://cli-chat-proxy.grok.com/v1", + apiKey: "access", + defaultHeaders: { "X-XAI-Token-Auth": "xai-grok-cli" } + }); + }); + + it("disables SDK retries so a metered subscription is not burned three times over", () => { + expect(xaiOAuthOptions("access").maxRetries).toBe(0); + }); +}); + +describe("buildXaiAuthUrl", () => { + const url = () => new URL(buildXaiAuthUrl("http://127.0.0.1:54321/callback", "challenge", "state-value")); + + it("uses the discovered authorize endpoint", () => { + expect(url().origin + url().pathname).toBe("https://auth.x.ai/oauth2/authorize"); + }); + + it("requests an authorization code with PKCE S256", () => { + const params = url().searchParams; + expect(params.get("response_type")).toBe("code"); + expect(params.get("code_challenge")).toBe("challenge"); + expect(params.get("code_challenge_method")).toBe("S256"); + expect(params.get("state")).toBe("state-value"); + }); + + it("identifies the client and the loopback redirect", () => { + const params = url().searchParams; + expect(params.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828"); + expect(params.get("redirect_uri")).toBe("http://127.0.0.1:54321/callback"); + expect(params.get("referrer")).toBe("grok-build"); + }); + + it("requests offline access so a refresh token is issued", () => { + expect(url().searchParams.get("scope")).toContain("offline_access"); + }); +}); + +describe("PKCE challenge derivation", () => { + it("is the base64url SHA-256 of the verifier", () => { + const verifier = generateCodeVerifier(); + + expect(generateCodeChallenge(verifier)).toBe(createHash("sha256").update(verifier).digest("base64url")); + }); +}); + +describe("ensureFreshXaiTokens", () => { + it("returns the existing tokens when they are not near expiry", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const current = tokens(Date.now() + 60 * 60 * 1000); + const result = await runFuture(ensureFreshXaiTokens(current)); + + expect(result).toEqual(current); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("refreshes within the expiry buffer and keeps the old refresh token when none is returned", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ access_token: "new-access", expires_in: 3600 }), { status: 200 })) + ); + + const result = await runFuture(ensureFreshXaiTokens(tokens(Date.now() + 60 * 1000))); + + expect(result.access_token).toBe("new-access"); + expect(result.refresh_token).toBe("old-refresh"); + expect(result.expiry_date).toBeGreaterThan(Date.now()); + }); + + it("posts a form-encoded refresh_token grant with the client id", async () => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ access_token: "new-access", expires_in: 3600 }), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + + await runFuture(ensureFreshXaiTokens(tokens(0))); + + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("https://auth.x.ai/oauth2/token"); + expect((init.headers as Record)["Content-Type"]).toBe("application/x-www-form-urlencoded"); + const body = new URLSearchParams(init.body as string); + expect(body.get("grant_type")).toBe("refresh_token"); + expect(body.get("refresh_token")).toBe("old-refresh"); + expect(body.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828"); + }); + + it("prefers a rotated refresh token when the server returns one", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ access_token: "new-access", refresh_token: "rotated", expires_in: 3600 }), { status: 200 })) + ); + + const result = await runFuture(ensureFreshXaiTokens(tokens(0))); + + expect(result.refresh_token).toBe("rotated"); + }); + + it("tells the user to re-authenticate when the refresh token was revoked", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 })) + ); + + await expect(runFuture(ensureFreshXaiTokens(tokens(0)))).rejects.toThrow("commit-tools setup"); + }); + + it("surfaces other refresh failures", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("upstream exploded", { status: 500 })) + ); + + await expect(runFuture(ensureFreshXaiTokens(tokens(0)))).rejects.toThrow("xAI token refresh failed"); + }); + + it("rejects a refresh response with no access token", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ expires_in: 3600 }), { status: 200 })) + ); + + await expect(runFuture(ensureFreshXaiTokens(tokens(0)))).rejects.toThrow("no access_token"); + }); +}); diff --git a/test/infra/llm/xai.test.ts b/test/infra/llm/xai.test.ts index e840a96..82ef528 100644 --- a/test/infra/llm/xai.test.ts +++ b/test/infra/llm/xai.test.ts @@ -154,6 +154,23 @@ describe("generateContentWithXai", () => { await expect(runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" }))).rejects.toThrow("Response text is empty or missing"); }); + it("routes subscription OAuth through the CLI proxy with the user-token header", async () => { + create.mockResolvedValue(completion); + const config = { + ...configWith(Nothing()), + auth_method: { type: "xai_oauth" as const, content: { access_token: "grok-access", refresh_token: "r", expiry_date: 1 } } + }; + + await runFuture(generateContentWithXai(config, { prompt: "diff" })); + + expect(constructed[0]).toMatchObject({ + baseURL: "https://cli-chat-proxy.grok.com/v1", + apiKey: "grok-access", + defaultHeaders: { "X-XAI-Token-Auth": "xai-grok-cli" }, + maxRetries: 0 + }); + }); + it("rejects an auth method xAI does not support", async () => { const config = { ...configWith(Nothing()), From 8b052614933b65b12a30be839dbae1680334dec9 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sat, 1 Aug 2026 22:18:18 -0300 Subject: [PATCH 4/5] Send a client version so the Grok proxy accepts completions - Add `x-grok-client-version` to the OAuth client headers; `/chat/completions` on the proxy returns HTTP 426 without it and enforces a server-side minimum version. - Take the value from `xai-grok-pager` in xai-org/grok-build, and record in `XAI_CLIENT_VERSION` that it must be bumped whenever xAI raises the floor. - Map a 426 to a message naming `XAI_CLIENT_VERSION`, since xAI's own text tells the user to run `grok update`, which does not apply to this CLI. - Re-export `APIError` from the `openai` mock, which the new status check needs. - Cover the version header and the 426 message with tests. --- src/infra/auth/xai.ts | 11 +++++++++-- src/infra/llm/xai.ts | 10 +++++++++- test/infra/auth/xai.test.ts | 8 ++++++++ test/infra/llm/xai.test.ts | 22 ++++++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/infra/auth/xai.ts b/src/infra/auth/xai.ts index 3a44a8a..0bf4958 100644 --- a/src/infra/auth/xai.ts +++ b/src/infra/auth/xai.ts @@ -39,14 +39,21 @@ const SCOPES = "openid profile email offline_access grok-cli:access api:access"; const OAUTH_TIMEOUT_MS = 300_000; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; +// `/chat/completions` on the proxy rejects requests with no client version (HTTP 426) and +// enforces a server-side minimum, so this must be bumped whenever xAI raises it. Taken from +// `crates/codegen/xai-grok-pager/Cargo.toml` in xai-org/grok-build (read 2026-08-01). +// `/models` does not enforce it, so a stale value fails only at generation time. +const XAI_CLIENT_VERSION = "0.2.117"; + /** xAI's API is OpenAI-compatible, so the `openai` client serves it with only a `baseURL` change. */ const xaiApiKeyOptions = (apiKey: string): ClientOptions => ({ baseURL: XAI_API_BASE_URL, apiKey, maxRetries: 3, timeout: 120_000 }); const xaiOAuthOptions = (accessToken: string): ClientOptions => ({ baseURL: XAI_PROXY_BASE_URL, apiKey: accessToken, - // Tells the proxy the bearer is a user token rather than a deployment key. - defaultHeaders: { "X-XAI-Token-Auth": "xai-grok-cli" }, + // `X-XAI-Token-Auth` tells the proxy the bearer is a user token rather than a deployment + // key; `x-grok-client-version` clears its minimum-version gate. + defaultHeaders: { "X-XAI-Token-Auth": "xai-grok-cli", "x-grok-client-version": XAI_CLIENT_VERSION }, // The proxy meters a subscription, so SDK-level retries would multiply quota burn on a // 429. `withTransientRetry` already owns retry policy for every provider. maxRetries: 0, diff --git a/src/infra/llm/xai.ts b/src/infra/llm/xai.ts index ec9ff6c..1179ec8 100644 --- a/src/infra/llm/xai.ts +++ b/src/infra/llm/xai.ts @@ -34,6 +34,14 @@ const buildParams = (model: string, effort: Maybe, params: GenerateCo /** Grok accepts `reasoning_effort` on some models and rejects it on the rest, and `/v1/models` does not say which. */ const isUnsupportedEffort = (error: unknown): boolean => error instanceof OpenAI.BadRequestError && /reasoning_effort/i.test(error.message); +/** The proxy's minimum-version gate. Its own message says to run `grok update`, which does not apply here. */ +const isOutdatedClient = (error: unknown): boolean => error instanceof OpenAI.APIError && error.status === 426; + +const describeFailure = (error: unknown): string => + isOutdatedClient(error) ? + `xAI rejected the client version. Bump XAI_CLIENT_VERSION in src/infra/auth/xai.ts to the version named here: ${error instanceof Error ? error.message : String(error)}` + : `Failed to create xAI completion: ${error instanceof Error ? error.message : String(error)}`; + const requestCompletion = async (client: OpenAI, model: string, effort: Maybe, params: GenerateContentParams): Promise => { const attempt = async (attemptedEffort: Maybe): Promise => ({ completion: await client.chat.completions.create(buildParams(model, attemptedEffort, params)), @@ -55,7 +63,7 @@ const callXai = ( params: GenerateContentParams ): Future => Future.attemptP(() => requestCompletion(new OpenAI(options), model, effort, params)) - .mapRej((error) => new Error(`Failed to create xAI completion: ${error instanceof Error ? error.message : String(error)}`, { cause: error })) + .mapRej((error) => new Error(describeFailure(error), { cause: error })) .chain(({ completion, attemptedEffort }) => extractResponse({ text: fromOptional(completion.choices[0]?.message?.content ?? undefined) }).map((text) => ({ text, diff --git a/test/infra/auth/xai.test.ts b/test/infra/auth/xai.test.ts index 733066f..10192fe 100644 --- a/test/infra/auth/xai.test.ts +++ b/test/infra/auth/xai.test.ts @@ -28,6 +28,14 @@ describe("xaiOAuthOptions", () => { it("disables SDK retries so a metered subscription is not burned three times over", () => { expect(xaiOAuthOptions("access").maxRetries).toBe(0); }); + + it("sends a client version clearing the proxy's minimum-version gate", () => { + const version = (xaiOAuthOptions("access").defaultHeaders as Record)["x-grok-client-version"]; + + // The proxy returns 426 when the header is absent, and enforces a server-side floor. + expect(version).toMatch(/^\d+\.\d+\.\d+$/); + expect(version?.startsWith("0.1.")).toBe(false); + }); }); describe("buildXaiAuthUrl", () => { diff --git a/test/infra/llm/xai.test.ts b/test/infra/llm/xai.test.ts index 82ef528..4f5fc7c 100644 --- a/test/infra/llm/xai.test.ts +++ b/test/infra/llm/xai.test.ts @@ -15,6 +15,7 @@ vi.mock("openai", async (importOriginal) => { class MockOpenAI { static BadRequestError = actual.default.BadRequestError; + static APIError = actual.default.APIError; readonly chat = { completions: { create } }; constructor(options: unknown) { constructed.push(options); @@ -171,6 +172,27 @@ describe("generateContentWithXai", () => { }); }); + it("points a version rejection at the constant rather than xAI's `grok update` advice", async () => { + const error = new OpenAI.APIError( + 426, + undefined, + "Your Grok CLI version (none) is outdated. Please update to version 0.1.202 or later via `grok update`.", + new Headers() + ); + create.mockRejectedValue(error); + + await expect(runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" }))).rejects.toThrow( + /Bump XAI_CLIENT_VERSION in src\/infra\/auth\/xai\.ts/ + ); + }); + + it("keeps xAI's own text so the required version is visible", async () => { + const error = new OpenAI.APIError(426, undefined, "Please update to version 0.1.202 or later", new Headers()); + create.mockRejectedValue(error); + + await expect(runFuture(generateContentWithXai(configWith(Nothing()), { prompt: "diff" }))).rejects.toThrow(/0\.1\.202/); + }); + it("rejects an auth method xAI does not support", async () => { const config = { ...configWith(Nothing()), From a30039e2f54f0762b7b60e83712dcfee86f07e13 Mon Sep 17 00:00:00 2001 From: rafaeelricco Date: Sun, 2 Aug 2026 12:18:13 -0300 Subject: [PATCH 5/5] Filter non-chat models out of the xAI catalog - Keep only `grok-` ids that do not include `-image` in `fetchXaiModelsWith`, matching the OpenAI chat-only prefix filter so setup cannot persist image models. - Cover image and non-grok exclusions with a unit test on the xAI catalog path. --- src/domain/commit/models.ts | 5 ++++- test/domain/commit/models.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/domain/commit/models.ts b/src/domain/commit/models.ts index fdea7b9..7f6b769 100644 --- a/src/domain/commit/models.ts +++ b/src/domain/commit/models.ts @@ -142,7 +142,10 @@ const fetchXaiModelsWith = (options: ClientOptions): Future => for await (const model of list) { models.push(model); } - return models.sort((a, b) => a.id.localeCompare(b.id)).map((m) => ({ id: m.id, description: "", openaiEffort: Nothing() })); + return models + .filter((m) => m.id.startsWith("grok-") && !m.id.includes("-image")) + .sort((a, b) => a.id.localeCompare(b.id)) + .map((m) => ({ id: m.id, description: "", openaiEffort: Nothing() })); }).mapRej((error) => new Error(`Failed to fetch xAI models: ${error instanceof Error ? error.message : String(error)}`)); const fetchXaiModels = (authMethod: ProviderConfig["auth_method"]): Future => { diff --git a/test/domain/commit/models.test.ts b/test/domain/commit/models.test.ts index ed4f767..8b8c3f3 100644 --- a/test/domain/commit/models.test.ts +++ b/test/domain/commit/models.test.ts @@ -82,6 +82,14 @@ describe("fetchModels", () => { expect(models.map((m) => m.id)).toEqual(["grok-3", "grok-4.5"]); }); + it("filters non-chat xAI models out of the catalog", async () => { + list.mockResolvedValue(asyncPageOf(["grok-4.5", "grok-2-image-1212", "text-embedding-3-small"])); + + const models = await runFuture(fetchModels("xai", { type: "api_key", content: "xai-test" })); + + expect(models.map((m) => m.id)).toEqual(["grok-4.5"]); + }); + it("lists xAI models over subscription OAuth through the CLI proxy", async () => { list.mockResolvedValue(asyncPageOf(["grok-4.5"]));