Skip to content

Commit db31c0c

Browse files
committed
feat: add xAI Grok provider support
1 parent cefd87f commit db31c0c

15 files changed

+396
-2
lines changed

src/cli/program/register.onboard.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function registerOnboardCommand(program: Command) {
5858
.option("--mode <mode>", "Wizard mode: local|remote")
5959
.option(
6060
"--auth-choice <choice>",
61-
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|cloudflare-ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
61+
"Auth: setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|cloudflare-ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|xiaomi-api-key|xai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
6262
)
6363
.option(
6464
"--token-provider <id>",
@@ -86,6 +86,7 @@ export function registerOnboardCommand(program: Command) {
8686
.option("--synthetic-api-key <key>", "Synthetic API key")
8787
.option("--venice-api-key <key>", "Venice API key")
8888
.option("--opencode-zen-api-key <key>", "OpenCode Zen API key")
89+
.option("--xai-api-key <key>", "xAI API key")
8990
.option("--gateway-port <port>", "Gateway port")
9091
.option("--gateway-bind <mode>", "Gateway bind: loopback|tailnet|lan|auto|custom")
9192
.option("--gateway-auth <mode>", "Gateway auth: token|password")
@@ -140,6 +141,7 @@ export function registerOnboardCommand(program: Command) {
140141
syntheticApiKey: opts.syntheticApiKey as string | undefined,
141142
veniceApiKey: opts.veniceApiKey as string | undefined,
142143
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
144+
xaiApiKey: opts.xaiApiKey as string | undefined,
143145
gatewayPort:
144146
typeof gatewayPort === "number" && Number.isFinite(gatewayPort)
145147
? gatewayPort

src/commands/auth-choice-options.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,14 @@ describe("buildAuthChoiceOptions", () => {
114114

115115
expect(options.some((opt) => opt.value === "qwen-portal")).toBe(true);
116116
});
117+
118+
it("includes xAI auth choice", () => {
119+
const store: AuthProfileStore = { version: 1, profiles: {} };
120+
const options = buildAuthChoiceOptions({
121+
store,
122+
includeSkip: false,
123+
});
124+
125+
expect(options.some((opt) => opt.value === "xai-api-key")).toBe(true);
126+
});
117127
});

src/commands/auth-choice-options.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ export type AuthChoiceGroupId =
2222
| "minimax"
2323
| "synthetic"
2424
| "venice"
25-
| "qwen";
25+
| "qwen"
26+
| "xai";
2627

2728
export type AuthChoiceGroup = {
2829
value: AuthChoiceGroupId;
@@ -37,6 +38,12 @@ const AUTH_CHOICE_GROUP_DEFS: {
3738
hint?: string;
3839
choices: AuthChoice[];
3940
}[] = [
41+
{
42+
value: "xai",
43+
label: "xAI (Grok)",
44+
hint: "API key",
45+
choices: ["xai-api-key"],
46+
},
4047
{
4148
value: "openai",
4249
label: "OpenAI",
@@ -149,6 +156,7 @@ export function buildAuthChoiceOptions(params: {
149156
options.push({ value: "chutes", label: "Chutes (OAuth)" });
150157
options.push({ value: "openai-api-key", label: "OpenAI API key" });
151158
options.push({ value: "openrouter-api-key", label: "OpenRouter API key" });
159+
options.push({ value: "xai-api-key", label: "xAI (Grok) API key" });
152160
options.push({
153161
value: "ai-gateway-api-key",
154162
label: "Vercel AI Gateway API key",

src/commands/auth-choice.apply.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { applyAuthChoiceMiniMax } from "./auth-choice.apply.minimax.js";
1212
import { applyAuthChoiceOAuth } from "./auth-choice.apply.oauth.js";
1313
import { applyAuthChoiceOpenAI } from "./auth-choice.apply.openai.js";
1414
import { applyAuthChoiceQwenPortal } from "./auth-choice.apply.qwen-portal.js";
15+
import { applyAuthChoiceXAI } from "./auth-choice.apply.xai.js";
1516

1617
export type ApplyAuthChoiceParams = {
1718
authChoice: AuthChoice;
@@ -27,6 +28,7 @@ export type ApplyAuthChoiceParams = {
2728
cloudflareAiGatewayAccountId?: string;
2829
cloudflareAiGatewayGatewayId?: string;
2930
cloudflareAiGatewayApiKey?: string;
31+
xaiApiKey?: string;
3032
};
3133
};
3234

@@ -49,6 +51,7 @@ export async function applyAuthChoice(
4951
applyAuthChoiceGoogleGeminiCli,
5052
applyAuthChoiceCopilotProxy,
5153
applyAuthChoiceQwenPortal,
54+
applyAuthChoiceXAI,
5255
];
5356

5457
for (const handler of handlers) {
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
2+
import { resolveEnvApiKey } from "../agents/model-auth.js";
3+
import {
4+
formatApiKeyPreview,
5+
normalizeApiKeyInput,
6+
validateApiKeyInput,
7+
} from "./auth-choice.api-key.js";
8+
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
9+
import {
10+
applyAuthProfileConfig,
11+
applyXaiConfig,
12+
applyXaiProviderConfig,
13+
setXaiApiKey,
14+
XAI_DEFAULT_MODEL_REF,
15+
} from "./onboard-auth.js";
16+
17+
export async function applyAuthChoiceXAI(
18+
params: ApplyAuthChoiceParams,
19+
): Promise<ApplyAuthChoiceResult | null> {
20+
if (params.authChoice !== "xai-api-key") {
21+
return null;
22+
}
23+
24+
let nextConfig = params.config;
25+
let agentModelOverride: string | undefined;
26+
const noteAgentModel = async (model: string) => {
27+
if (!params.agentId) {
28+
return;
29+
}
30+
await params.prompter.note(
31+
`Default model set to ${model} for agent "${params.agentId}".`,
32+
"Model configured",
33+
);
34+
};
35+
36+
let hasCredential = false;
37+
const optsKey = params.opts?.xaiApiKey?.trim();
38+
if (optsKey) {
39+
await setXaiApiKey(normalizeApiKeyInput(optsKey), params.agentDir);
40+
hasCredential = true;
41+
}
42+
43+
if (!hasCredential) {
44+
const envKey = resolveEnvApiKey("xai");
45+
if (envKey) {
46+
const useExisting = await params.prompter.confirm({
47+
message: `Use existing XAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
48+
initialValue: true,
49+
});
50+
if (useExisting) {
51+
await setXaiApiKey(envKey.apiKey, params.agentDir);
52+
hasCredential = true;
53+
}
54+
}
55+
}
56+
57+
if (!hasCredential) {
58+
const key = await params.prompter.text({
59+
message: "Enter xAI API key",
60+
validate: validateApiKeyInput,
61+
});
62+
await setXaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir);
63+
}
64+
65+
nextConfig = applyAuthProfileConfig(nextConfig, {
66+
profileId: "xai:default",
67+
provider: "xai",
68+
mode: "api_key",
69+
});
70+
{
71+
const applied = await applyDefaultModelChoice({
72+
config: nextConfig,
73+
setDefaultModel: params.setDefaultModel,
74+
defaultModel: XAI_DEFAULT_MODEL_REF,
75+
applyDefaultConfig: applyXaiConfig,
76+
applyProviderConfig: applyXaiProviderConfig,
77+
noteDefault: XAI_DEFAULT_MODEL_REF,
78+
noteAgentModel,
79+
prompter: params.prompter,
80+
});
81+
nextConfig = applied.config;
82+
agentModelOverride = applied.agentModelOverride ?? agentModelOverride;
83+
}
84+
85+
return { config: nextConfig, agentModelOverride };
86+
}

src/commands/auth-choice.preferred-provider.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const PREFERRED_PROVIDER_BY_AUTH_CHOICE: Partial<Record<AuthChoice, string>> = {
3030
"minimax-api-lightning": "minimax",
3131
minimax: "lmstudio",
3232
"opencode-zen": "opencode",
33+
"xai-api-key": "xai",
3334
"qwen-portal": "qwen-portal",
3435
"minimax-portal": "minimax-portal",
3536
};

src/commands/auth-choice.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,60 @@ describe("applyAuthChoice", () => {
193193
expect(parsed.profiles?.["synthetic:default"]?.key).toBe("sk-synthetic-test");
194194
});
195195

196+
it("does not override the global default model when selecting xai-api-key without setDefaultModel", async () => {
197+
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
198+
process.env.OPENCLAW_STATE_DIR = tempStateDir;
199+
process.env.OPENCLAW_AGENT_DIR = path.join(tempStateDir, "agent");
200+
process.env.PI_CODING_AGENT_DIR = process.env.OPENCLAW_AGENT_DIR;
201+
202+
const text = vi.fn().mockResolvedValue("sk-xai-test");
203+
const select: WizardPrompter["select"] = vi.fn(
204+
async (params) => params.options[0]?.value as never,
205+
);
206+
const multiselect: WizardPrompter["multiselect"] = vi.fn(async () => []);
207+
const prompter: WizardPrompter = {
208+
intro: vi.fn(noopAsync),
209+
outro: vi.fn(noopAsync),
210+
note: vi.fn(noopAsync),
211+
select,
212+
multiselect,
213+
text,
214+
confirm: vi.fn(async () => false),
215+
progress: vi.fn(() => ({ update: noop, stop: noop })),
216+
};
217+
const runtime: RuntimeEnv = {
218+
log: vi.fn(),
219+
error: vi.fn(),
220+
exit: vi.fn((code: number) => {
221+
throw new Error(`exit:${code}`);
222+
}),
223+
};
224+
225+
const result = await applyAuthChoice({
226+
authChoice: "xai-api-key",
227+
config: { agents: { defaults: { model: { primary: "openai/gpt-4o-mini" } } } },
228+
prompter,
229+
runtime,
230+
setDefaultModel: false,
231+
agentId: "agent-1",
232+
});
233+
234+
expect(text).toHaveBeenCalledWith(expect.objectContaining({ message: "Enter xAI API key" }));
235+
expect(result.config.auth?.profiles?.["xai:default"]).toMatchObject({
236+
provider: "xai",
237+
mode: "api_key",
238+
});
239+
expect(result.config.agents?.defaults?.model?.primary).toBe("openai/gpt-4o-mini");
240+
expect(result.agentModelOverride).toBe("xai/grok-2-latest");
241+
242+
const authProfilePath = authProfilePathFor(requireAgentDir());
243+
const raw = await fs.readFile(authProfilePath, "utf8");
244+
const parsed = JSON.parse(raw) as {
245+
profiles?: Record<string, { key?: string }>;
246+
};
247+
expect(parsed.profiles?.["xai:default"]?.key).toBe("sk-xai-test");
248+
});
249+
196250
it("sets default model when selecting github-copilot", async () => {
197251
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-auth-"));
198252
process.env.OPENCLAW_STATE_DIR = tempStateDir;

src/commands/onboard-auth.config-core.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,18 @@ import {
2222
VERCEL_AI_GATEWAY_DEFAULT_MODEL_REF,
2323
XIAOMI_DEFAULT_MODEL_REF,
2424
ZAI_DEFAULT_MODEL_REF,
25+
XAI_DEFAULT_MODEL_REF,
2526
} from "./onboard-auth.credentials.js";
2627
import {
2728
buildMoonshotModelDefinition,
29+
buildXaiModelDefinition,
2830
KIMI_CODING_MODEL_REF,
2931
MOONSHOT_BASE_URL,
3032
MOONSHOT_CN_BASE_URL,
3133
MOONSHOT_DEFAULT_MODEL_ID,
3234
MOONSHOT_DEFAULT_MODEL_REF,
35+
XAI_BASE_URL,
36+
XAI_DEFAULT_MODEL_ID,
3337
} from "./onboard-auth.models.js";
3438

3539
export function applyZaiConfig(cfg: OpenClawConfig): OpenClawConfig {
@@ -588,6 +592,71 @@ export function applyVeniceConfig(cfg: OpenClawConfig): OpenClawConfig {
588592
};
589593
}
590594

595+
export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
596+
const models = { ...cfg.agents?.defaults?.models };
597+
models[XAI_DEFAULT_MODEL_REF] = {
598+
...models[XAI_DEFAULT_MODEL_REF],
599+
alias: models[XAI_DEFAULT_MODEL_REF]?.alias ?? "Grok",
600+
};
601+
602+
const providers = { ...cfg.models?.providers };
603+
const existingProvider = providers.xai;
604+
const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : [];
605+
const defaultModel = buildXaiModelDefinition();
606+
const hasDefaultModel = existingModels.some((model) => model.id === XAI_DEFAULT_MODEL_ID);
607+
const mergedModels = hasDefaultModel ? existingModels : [...existingModels, defaultModel];
608+
const { apiKey: existingApiKey, ...existingProviderRest } = (existingProvider ?? {}) as Record<
609+
string,
610+
unknown
611+
> as { apiKey?: string };
612+
const resolvedApiKey = typeof existingApiKey === "string" ? existingApiKey : undefined;
613+
const normalizedApiKey = resolvedApiKey?.trim();
614+
providers.xai = {
615+
...existingProviderRest,
616+
baseUrl: XAI_BASE_URL,
617+
api: "openai-completions",
618+
...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}),
619+
models: mergedModels.length > 0 ? mergedModels : [defaultModel],
620+
};
621+
622+
return {
623+
...cfg,
624+
agents: {
625+
...cfg.agents,
626+
defaults: {
627+
...cfg.agents?.defaults,
628+
models,
629+
},
630+
},
631+
models: {
632+
mode: cfg.models?.mode ?? "merge",
633+
providers,
634+
},
635+
};
636+
}
637+
638+
export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig {
639+
const next = applyXaiProviderConfig(cfg);
640+
const existingModel = next.agents?.defaults?.model;
641+
return {
642+
...next,
643+
agents: {
644+
...next.agents,
645+
defaults: {
646+
...next.agents?.defaults,
647+
model: {
648+
...(existingModel && "fallbacks" in (existingModel as Record<string, unknown>)
649+
? {
650+
fallbacks: (existingModel as { fallbacks?: string[] }).fallbacks,
651+
}
652+
: undefined),
653+
primary: XAI_DEFAULT_MODEL_REF,
654+
},
655+
},
656+
},
657+
};
658+
}
659+
591660
export function applyAuthProfileConfig(
592661
cfg: OpenClawConfig,
593662
params: {

src/commands/onboard-auth.credentials.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { OAuthCredentials } from "@mariozechner/pi-ai";
22
import { resolveOpenClawAgentDir } from "../agents/agent-paths.js";
33
import { upsertAuthProfile } from "../agents/auth-profiles.js";
44
export { CLOUDFLARE_AI_GATEWAY_DEFAULT_MODEL_REF } from "../agents/cloudflare-ai-gateway.js";
5+
export { XAI_DEFAULT_MODEL_REF } from "./onboard-auth.models.js";
56

67
const resolveAuthAgentDir = (agentDir?: string) => agentDir ?? resolveOpenClawAgentDir();
78

@@ -203,3 +204,15 @@ export async function setOpencodeZenApiKey(key: string, agentDir?: string) {
203204
agentDir: resolveAuthAgentDir(agentDir),
204205
});
205206
}
207+
208+
export async function setXaiApiKey(key: string, agentDir?: string) {
209+
upsertAuthProfile({
210+
profileId: "xai:default",
211+
credential: {
212+
type: "api_key",
213+
provider: "xai",
214+
key,
215+
},
216+
agentDir: resolveAuthAgentDir(agentDir),
217+
});
218+
}

src/commands/onboard-auth.models.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,27 @@ export function buildMoonshotModelDefinition(): ModelDefinitionConfig {
9292
maxTokens: MOONSHOT_DEFAULT_MAX_TOKENS,
9393
};
9494
}
95+
96+
export const XAI_BASE_URL = "https://api.x.ai/v1";
97+
export const XAI_DEFAULT_MODEL_ID = "grok-2-latest";
98+
export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`;
99+
export const XAI_DEFAULT_CONTEXT_WINDOW = 131072;
100+
export const XAI_DEFAULT_MAX_TOKENS = 8192;
101+
export const XAI_DEFAULT_COST = {
102+
input: 0,
103+
output: 0,
104+
cacheRead: 0,
105+
cacheWrite: 0,
106+
};
107+
108+
export function buildXaiModelDefinition(): ModelDefinitionConfig {
109+
return {
110+
id: XAI_DEFAULT_MODEL_ID,
111+
name: "Grok 2",
112+
reasoning: false,
113+
input: ["text"],
114+
cost: XAI_DEFAULT_COST,
115+
contextWindow: XAI_DEFAULT_CONTEXT_WINDOW,
116+
maxTokens: XAI_DEFAULT_MAX_TOKENS,
117+
};
118+
}

0 commit comments

Comments
 (0)