fix(xai): follow OAuth default model automatically (#115617)

* fix(xai): default OAuth login to Grok 4.5

* fix(xai): follow OAuth default model automatically

* fix(xai): send OAuth proxy request headers

* fix(xai): avoid provider API shadowing

* fix(qa): restore current main validation gates

---------

Co-authored-by: echeung <echeung@agentsonly.com>
Co-authored-by: fuller-stack-dev <263060202+fuller-stack-dev@users.noreply.github.com>
This commit is contained in:
EricKwan
2026-07-29 16:05:29 -07:00
committed by GitHub
parent fc95d7190c
commit e00ef16ee7
18 changed files with 430 additions and 114 deletions

View File

@@ -328,7 +328,7 @@ messages and normalizes `stats.cached` into `cacheRead`; legacy
| Venice | `venice` | `VENICE_API_KEY` | - |
| Vercel AI Gateway | `vercel-ai-gateway` | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway/anthropic/claude-opus-4.6` |
| Volcano Engine (Doubao) | `volcengine` / `volcengine-plan` | `VOLCANO_ENGINE_API_KEY` | `volcengine-plan/ark-code-latest` |
| xAI | `xai` | SuperGrok/X Premium OAuth or `XAI_API_KEY` | `xai/grok-4.3` |
| xAI | `xai` | SuperGrok/X Premium OAuth or `XAI_API_KEY` | OAuth: `xai/auto`; API key: `xai/grok-4.3` |
| Xiaomi | `xiaomi` / `xiaomi-token-plan` | `XIAOMI_API_KEY` / `XIAOMI_TOKEN_PLAN_API_KEY` | `xiaomi/mimo-v2.5` / `xiaomi-token-plan/mimo-v2.5-pro` |
#### Quirks worth knowing
@@ -347,7 +347,7 @@ messages and normalizes `stats.cached` into `cacheRead`; legacy
Model ids use a `nvidia/<vendor>/<model>` namespace (for example `nvidia/nvidia/nemotron-...`); pickers preserve the literal `<provider>/<model-id>` composition while the canonical key sent to the API stays single-prefixed.
</Accordion>
<Accordion title="xAI">
Uses the xAI Responses path. The recommended path is SuperGrok/X Premium OAuth; API keys still work via `XAI_API_KEY` or plugin config, and Grok `web_search` reuses the same auth profile before API-key fallback. Grok 4.5 is selectable for chat, coding, and agentic work where available; `grok-4.3` remains the regional-safe bundled default. Older `/fast` and `params.fastMode: true` configurations still resolve through xAI's Grok 4.3 compatibility redirects, but new configurations should select a current model directly. `tool_stream` defaults on; disable via `agents.defaults.models["xai/<model>"].params.tool_stream=false`.
Uses the xAI Responses path. The recommended path is SuperGrok/X Premium OAuth; fresh setup selects `xai/auto`, which follows xAI's authenticated default model without an OpenClaw update. Existing concrete model ids stay pinned. API keys still work via `XAI_API_KEY` or plugin config and keep `grok-4.3` as the regional-safe setup default. Grok `web_search` reuses the same auth profile before API-key fallback. Older `/fast` and `params.fastMode: true` configurations still resolve through xAI's Grok 4.3 compatibility redirects, but new configurations should select a current model directly. `tool_stream` defaults on; disable via `agents.defaults.models["xai/<model>"].params.tool_stream=false`.
</Accordion>
</AccordionGroup>

View File

@@ -41,10 +41,13 @@ OAuth client.
openclaw models auth login --provider xai --method oauth
```
Apply Grok as the default model separately:
With no existing primary model, OAuth setup selects `xai/auto`. The plugin
resolves that stable ref from xAI's authenticated model catalog and remote
default, so future xAI default changes do not require an OpenClaw update.
It preserves an existing primary; opt in explicitly when needed:
```bash
openclaw models set xai/grok-4.3
openclaw models set xai/auto
```
Rerun full onboarding only if you intentionally want to change Gateway,
@@ -53,7 +56,8 @@ OAuth client.
</Step>
<Step title="API-key path">
API-key setup still works for xAI Console keys and for media surfaces
that need key-backed provider config:
that need key-backed provider config. It keeps Grok 4.3 as the
regional-safe setup default:
```bash
openclaw models auth login --provider xai --method api-key
@@ -64,7 +68,7 @@ OAuth client.
<Step title="Pick a model">
```json5
{
agents: { defaults: { model: { primary: "xai/grok-4.3" } } },
agents: { defaults: { model: { primary: "xai/auto" } } },
}
```
</Step>
@@ -85,7 +89,8 @@ bundled xAI model provider reuses it as a fallback too.
`openclaw models auth login --provider xai --method oauth`; it uses
device-code verification, not a localhost callback.
- If sign-in succeeds but Grok is not the default model, run
`openclaw models set xai/grok-4.3`.
`openclaw models set xai/auto`. OAuth login preserves an existing
primary model unless you explicitly change it.
- Inspect saved xAI auth profiles:
```bash
@@ -116,9 +121,10 @@ see [legacy compatibility and moving aliases](#legacy-compatibility-and-moving-a
| Grok 4.20 | `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` |
<Tip>
Use `grok-4.5` for general chat, coding, and agentic work where it is available.
Grok 4.3 remains the regional-safe setup default; `grok-build-0.1` and both
dated Grok 4.20 variants remain selectable.
Use `xai/auto` to follow xAI's authenticated OAuth default, or select a concrete
id such as `xai/grok-4.5` to remain pinned. API-key setup keeps Grok 4.3 as the
regional-safe default; `grok-build-0.1` and both dated Grok 4.20 variants remain
selectable.
</Tip>
Catalog context and token-cost metadata follows xAI's live

View File

@@ -99,6 +99,18 @@ function vectorToBlob(embedding: number[]): Buffer {
return Buffer.from(new Float32Array(embedding).buffer);
}
function insertCanonicalChunkProvenance(
db: DatabaseSync,
chunkId: string,
observedAt: number,
): void {
db.prepare(
`INSERT INTO memory_index_chunk_provenance (
chunk_id, origin_class, session_kind, observed_at
) VALUES (?, 'agent', 'unknown', ?)`,
).run(chunkId, observedAt);
}
async function writeLegacyMemorySidecar(
legacyPath: string,
params: {
@@ -222,6 +234,7 @@ async function createCanonicalMemoryIndex(agentPath: string, text: string): Prom
db.prepare(
"INSERT INTO memory_index_chunks_fts (text, id, path, source, model, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(text, "canonical-chunk", "MEMORY.md", "memory", "embed-model", 1, 1);
insertCanonicalChunkProvenance(db, "canonical-chunk", 31);
} finally {
db.close();
}
@@ -271,6 +284,7 @@ async function createUnrelatedCanonicalMemoryIndex(
1,
1,
);
insertCanonicalChunkProvenance(db, "canonical-other-chunk", 31);
} finally {
db.close();
}
@@ -309,6 +323,7 @@ async function createCanonicalLegacyMemoryRowsWithFts(agentPath: string, ftsText
db.prepare(
"INSERT INTO memory_index_chunks_fts (text, id, path, source, model, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(ftsText, "chunk-1", "MEMORY.md", "memory", "embed-model", 1, 2);
insertCanonicalChunkProvenance(db, "chunk-1", 30);
} finally {
db.close();
}
@@ -1415,7 +1430,7 @@ describe("memory-core doctor dreaming migration", () => {
expect.stringContaining("Archived Memory Core legacy memory index sidecar"),
]);
expect(readMemoryRows(agentPath)).toEqual({
sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }],
sources: [{ path: "MEMORY.md", source: "memory", hash: "" }],
chunks: [{ id: "chunk-1", text: "remember this" }],
cache: [{ provider: "openai", hash: "chunk-hash" }],
});
@@ -1483,7 +1498,7 @@ describe("memory-core doctor dreaming migration", () => {
expect.stringContaining("Archived Memory Core legacy memory index sidecar"),
]);
expect(readMemoryRows(agentPath)).toEqual({
sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }],
sources: [{ path: "MEMORY.md", source: "memory", hash: "" }],
chunks: [{ id: "chunk-1", text: "remember this" }],
cache: [{ provider: "openai", hash: "chunk-hash" }],
});
@@ -1658,7 +1673,7 @@ describe("memory-core doctor dreaming migration", () => {
]);
for (const agentPath of [mainAgentPath, workAgentPath]) {
expect(readMemoryRows(agentPath)).toEqual({
sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }],
sources: [{ path: "MEMORY.md", source: "memory", hash: "" }],
chunks: [{ id: "chunk-1", text: "remember this" }],
cache: [{ provider: "openai", hash: "chunk-hash" }],
});
@@ -1781,7 +1796,7 @@ describe("memory-core doctor dreaming migration", () => {
"Migrated Memory Core legacy memory index for agent main -> per-agent SQLite (1 source(s), 1 chunk(s), 1 cache row(s))",
]);
expect(readMemoryRows(agentPath)).toEqual({
sources: [{ path: "MEMORY.md", source: "memory", hash: "file-hash" }],
sources: [{ path: "MEMORY.md", source: "memory", hash: "" }],
chunks: [{ id: "chunk-1", text: "remember this" }],
cache: [{ provider: "openai", hash: "chunk-hash" }],
});
@@ -1970,24 +1985,15 @@ describe("memory-core doctor dreaming migration", () => {
expect(result.changes).toContain(
`Copied Memory Core legacy memory index sidecar retry path -> ${alternateRetryPath}`,
);
expect(retryPreview?.preview).toEqual(
expect.arrayContaining([
`- Memory Core legacy memory index: ${retryPath} -> ${path.join(
stateDir,
"agents",
"main",
"agent",
"openclaw-agent.sqlite",
)}`,
`- Memory Core legacy memory index: ${alternateRetryPath} -> ${path.join(
stateDir,
"agents",
"main",
"agent",
"openclaw-agent.sqlite",
)}`,
]),
);
expect(retryPreview?.preview).toEqual([
`- Memory Core legacy memory index: ${alternateRetryPath} -> ${path.join(
stateDir,
"agents",
"main",
"agent",
"openclaw-agent.sqlite",
)}`,
]);
await expect(fs.access(legacyPath)).resolves.toBeUndefined();
await expect(fs.access(alternateRetryPath)).resolves.toBeUndefined();
@@ -2005,7 +2011,7 @@ describe("memory-core doctor dreaming migration", () => {
expect.stringContaining("Copied Memory Core legacy memory index sidecar retry path"),
]),
);
expect(retryEntriesAfter).toEqual(retryEntriesBefore);
expect(retryEntriesAfter).toEqual(retryEntriesBefore.map((entry) => `${entry}.migrated`));
});
it("keeps canonical rows and archives a conflicting derived legacy index", async () => {
@@ -2192,7 +2198,7 @@ describe("memory-core doctor dreaming migration", () => {
]);
expect(readMemoryRows(agentPath)).toEqual({
sources: [
{ path: "MEMORY.md", source: "memory", hash: "file-hash" },
{ path: "MEMORY.md", source: "memory", hash: "" },
{ path: "OTHER.md", source: "memory", hash: "canonical-other-file-hash" },
],
chunks: [
@@ -2259,7 +2265,7 @@ describe("memory-core doctor dreaming migration", () => {
]);
expect(readMemoryRows(agentPath)).toEqual({
sources: [
{ path: "MEMORY.md", source: "memory", hash: "file-hash" },
{ path: "MEMORY.md", source: "memory", hash: "" },
{ path: "OTHER.md", source: "memory", hash: "canonical-other-file-hash" },
],
chunks: [

View File

@@ -69,10 +69,10 @@ describe("memory search provenance", () => {
endLine: 1,
});
db.prepare(
`UPDATE memory_index_chunk_provenance
SET origin_class = ?, session_kind = ?, observed_at = ?, supersedes_key = ?
WHERE chunk_id = ?`,
).run("owner", "interactive", 1234, "tea-preference", "provenance-hit");
`INSERT INTO memory_index_chunk_provenance (
chunk_id, origin_class, session_kind, observed_at, supersedes_key
) VALUES (?, ?, ?, ?, ?)`,
).run("provenance-hit", "owner", "interactive", 1234, "tea-preference");
const results = await searchKeyword({
db,

View File

@@ -535,7 +535,7 @@ describe("qa test file scenario runner", () => {
it.each([
{ evidence: "missing", expectedFailure: /without writing fresh producer QA evidence/u },
{ evidence: "stale", expectedFailure: /not written by the current scenario run/u },
{ evidence: "stale", expectedFailure: /without writing fresh producer QA evidence/u },
{ evidence: "empty", expectedFailure: /without reporting an executed producer check/u },
{ evidence: "malformed", expectedFailure: /invalid JSON/u },
{ evidence: "outside", expectedFailure: /inside its scenario output directory/u },

View File

@@ -354,16 +354,12 @@ async function runQaTestFileScenario(params: {
}) {
const requiresProducerEvidence =
params.scenario.execution.kind === "script" && !isDockerE2eScenario(params.scenario);
let producerEvidenceStartedAtMs: number | undefined;
if (requiresProducerEvidence) {
const scenarioOutputDir = path.join(params.outputDir, params.scenario.id);
// Both producer indexes belong to one command invocation. Clear them before
// launch so a successful no-op cannot authenticate an earlier scenario run.
await Promise.all([
fs.rm(path.join(scenarioOutputDir, "latest-run.json"), { force: true }),
fs.rm(path.join(scenarioOutputDir, QA_EVIDENCE_FILENAME), { force: true }),
]);
producerEvidenceStartedAtMs = Date.now();
// The whole producer artifact root belongs to one command invocation. Clear
// it so neither a stale index nor a stale bundle can authenticate a no-op.
await fs.rm(scenarioOutputDir, { force: true, recursive: true });
await fs.mkdir(scenarioOutputDir, { recursive: true });
}
const definition = testFileRunnerDefinitions[params.scenario.execution.kind];
const result = await runScenarioCommandSteps({
@@ -379,7 +375,7 @@ async function runQaTestFileScenario(params: {
outputDir: params.outputDir,
repoRoot: params.repoRoot,
scenario: params.scenario,
producerEvidenceStartedAtMs,
requireCurrentRunEvidence: requiresProducerEvidence,
});
} catch (error) {
if (result.status !== "pass") {

View File

@@ -84,14 +84,14 @@ function assertScenarioOwnsEvidencePath(scenarioOutputDir: string, evidencePath:
export async function readScriptProducerEvidence(params: {
outputDir: string;
producerEvidenceStartedAtMs?: number;
requireCurrentRunEvidence?: boolean;
repoRoot: string;
scenario: { id: string };
}): Promise<{ producerEvidence?: QaEvidenceSummaryJson }> {
const scenarioOutputDir = path.join(params.outputDir, params.scenario.id);
const latestRun = await readJsonFileIfExists(path.join(scenarioOutputDir, "latest-run.json"));
if (
params.producerEvidenceStartedAtMs !== undefined &&
params.requireCurrentRunEvidence === true &&
latestRun !== undefined &&
(latestRun === null ||
typeof latestRun !== "object" ||
@@ -117,7 +117,7 @@ export async function readScriptProducerEvidence(params: {
const evidencePath = path.isAbsolute(candidate)
? candidate
: path.join(scenarioOutputDir, candidate);
if (params.producerEvidenceStartedAtMs !== undefined) {
if (params.requireCurrentRunEvidence === true) {
assertScenarioOwnsEvidencePath(scenarioOutputDir, evidencePath);
const evidenceStat = await fs.stat(evidencePath).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
@@ -128,9 +128,6 @@ export async function readScriptProducerEvidence(params: {
if (!evidenceStat) {
continue;
}
if (evidenceStat.mtimeMs < params.producerEvidenceStartedAtMs) {
throw new Error("producer evidence was not written by the current scenario run");
}
assertScenarioOwnsEvidencePath(
await fs.realpath(scenarioOutputDir),
await fs.realpath(evidencePath),

View File

@@ -20,7 +20,7 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMo
import plugin from "./index.js";
import manifest from "./openclaw.plugin.json" with { type: "json" };
import { buildLiveXaiProvider } from "./provider-catalog.js";
import { buildLiveXaiOAuthProvider, buildLiveXaiProvider } from "./provider-catalog.js";
import setupPlugin from "./setup-api.js";
import {
createXaiPayloadCaptureStream,
@@ -192,8 +192,12 @@ describe("xai provider plugin", () => {
source: "profile:xai-profile",
profileId: "xai-profile",
});
const fetchMock = vi.fn(async () =>
Response.json({
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/settings")) {
return Response.json({ default_model: "grok-build" });
}
return Response.json({
data: [
{
id: "grok-composer-2.5-fast",
@@ -216,8 +220,8 @@ describe("xai provider plugin", () => {
api_backend: "image",
},
],
}),
);
});
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const provider = await registerSingleProviderPlugin(plugin);
@@ -246,9 +250,18 @@ describe("xai provider plugin", () => {
expect(result.provider.auth).toBe("oauth");
expect(result.provider.apiKey).toBeUndefined();
expect(result.provider.models.map((model) => model.id)).toEqual([
"auto",
"grok-composer-2.5-fast",
"grok-build",
]);
const auto = result.provider.models.find((model) => model.id === "auto");
expect(auto?.params?.canonicalModelId).toBe("grok-build");
const normalizedAuto = provider.normalizeResolvedModel?.({
provider: "xai",
modelId: "auto",
model: { ...auto, provider: "xai" },
} as never);
expect(normalizedAuto?.id).toBe("grok-build");
const composer = result.provider.models.find((model) => model.id === "grok-composer-2.5-fast");
if (!composer) {
throw new Error("expected OAuth Composer model");
@@ -284,11 +297,53 @@ describe("xai provider plugin", () => {
profileId: "xai-profile",
lockedProfile: true,
});
const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined;
expect(fetchCall?.[0]).toBe("https://cli-chat-proxy.grok.com/v1/models");
expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe(
const modelFetchCall = fetchMock.mock.calls.find(
([input]) => input === "https://cli-chat-proxy.grok.com/v1/models",
) as unknown as [string, RequestInit] | undefined;
const settingsFetchCall = fetchMock.mock.calls.find(
([input]) => input === "https://cli-chat-proxy.grok.com/v1/settings",
) as unknown as [string, RequestInit] | undefined;
expect(new Headers(modelFetchCall?.[1]?.headers).get("Authorization")).toBe(
"Bearer xai-oauth-token",
);
expect(new Headers(settingsFetchCall?.[1]?.headers).get("Authorization")).toBe(
"Bearer xai-oauth-token",
);
});
it("updates xAI OAuth auto from remote settings without a catalog code change", async () => {
let remoteDefault = "grok-4-5";
const release = vi.fn(async () => undefined);
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({
response: url.endsWith("/settings")
? Response.json({ default_model: remoteDefault })
: Response.json({
data: [
{ id: "grok-4.5", api_backend: "responses" },
{ id: "grok-next", api_backend: "responses" },
],
}),
finalUrl: url,
release,
}));
const first = await buildLiveXaiOAuthProvider({
discoveryApiKey: "xai-oauth-token",
fetchGuard,
});
expect(first.models.find((model) => model.id === "auto")?.params?.canonicalModelId).toBe(
"grok-4.5",
);
clearLiveCatalogCacheForTests();
remoteDefault = "grok-next";
const next = await buildLiveXaiOAuthProvider({
discoveryApiKey: "xai-oauth-token",
fetchGuard,
});
expect(next.models.find((model) => model.id === "auto")?.params?.canonicalModelId).toBe(
"grok-next",
);
});
it("uses runtime OAuth profiles when xAI catalog auth resolution is empty", async () => {
@@ -328,7 +383,8 @@ describe("xai provider plugin", () => {
}
expect(result.provider.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1");
expect(result.provider.auth).toBe("oauth");
expect(result.provider.models.map((model) => model.id)).toEqual(["grok-build"]);
expect(result.provider.models.map((model) => model.id)).toEqual(["auto", "grok-build"]);
expect(result.provider.models[0]?.params?.canonicalModelId).toBe("grok-build");
expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({
provider: "xai",
cfg: { models: {} },
@@ -376,6 +432,7 @@ describe("xai provider plugin", () => {
expect(result.provider.baseUrl).toBe("https://cli-chat-proxy.grok.com/v1");
expect(result.provider.auth).toBe("oauth");
expect(result.provider.apiKey).toBeUndefined();
expect(result.provider.models.map((model) => model.id)).toContain("auto");
expect(result.provider.models.map((model) => model.id)).toContain("grok-build-0.1");
});
@@ -415,6 +472,8 @@ describe("xai provider plugin", () => {
expect(result.provider.baseUrl).toBe("https://api.x.ai/v1");
expect(result.provider.apiKey).toBe("env-xai-key");
expect(result.provider.auth).toBeUndefined();
expect(result.provider.models.map((model) => model.id)).toEqual(["grok-4.3"]);
expect(result.provider.models.some((model) => model.id === "auto")).toBe(false);
const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined;
expect(fetchCall?.[0]).toBe("https://api.x.ai/v1/models");
expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe("Bearer env-xai-key");
@@ -769,6 +828,7 @@ describe("xai provider plugin", () => {
modelId: "grok-4.3",
model: createProviderModel({ id: "grok-4.3" }),
} as never);
expect(normalized?.id).toBe("grok-4.3");
expect(normalized?.thinkingLevelMap).toEqual({
off: "none",
minimal: "low",

View File

@@ -6,7 +6,6 @@ import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-mod
import { defaultToolStreamExtraParams } from "openclaw/plugin-sdk/provider-stream-shared";
import { jsonResult } from "openclaw/plugin-sdk/provider-web-search";
import {
applyXaiRuntimeModelCompat,
buildXaiImageGenerationProvider,
normalizeXaiModelId,
resolveXaiTransport,
@@ -22,7 +21,11 @@ import {
buildXaiProvider,
} from "./provider-catalog.js";
import { isXaiProviderId } from "./provider-id.js";
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
import {
isModernXaiModel,
normalizeXaiResolvedModel,
resolveXaiForwardCompatModel,
} from "./provider-models.js";
import { resolveThinkingProfile } from "./provider-policy-api.js";
import { buildXaiRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js";
import { buildXaiRealtimeVoiceProvider } from "./realtime-voice-provider.js";
@@ -172,7 +175,7 @@ export default defineSingleProviderPluginEntry({
id: "xai",
name: "xAI Plugin",
description: "Bundled xAI plugin",
provider: {
provider: (pluginApi) => ({
label: "xAI",
aliases: ["x-ai"],
docsPath: "/providers/xai",
@@ -251,7 +254,10 @@ export default defineSingleProviderPluginEntry({
},
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams),
wrapStreamFn: wrapXaiProviderStream,
wrapStreamFn: (ctx) =>
wrapXaiProviderStream(ctx, {
clientVersion: pluginApi.runtime.version,
}),
// Provider-specific fallback auth stays owned by the xAI plugin so core
// auth/discovery code can consume it generically without parsing xAI's
// private config layout. Callers may receive a real key from the active
@@ -267,7 +273,7 @@ export default defineSingleProviderPluginEntry({
mode: "api-key" as const,
};
},
normalizeResolvedModel: ({ model }) => applyXaiRuntimeModelCompat(model),
normalizeResolvedModel: ({ model }) => normalizeXaiResolvedModel(model),
normalizeTransport: ({ provider, api, baseUrl }) =>
resolveXaiTransport({ provider, api, baseUrl }),
normalizeModelId: ({ modelId }) => normalizeXaiModelId(modelId),
@@ -276,7 +282,7 @@ export default defineSingleProviderPluginEntry({
resolveThinkingProfile,
isModernModelRef: ({ modelId }) => isModernXaiModel(modelId),
classifyFailoverReason: ({ errorMessage }) => classifyXaiFailoverReason(errorMessage),
},
}),
register(api) {
api.registerWebSearchProvider(createXaiWebSearchProvider());
api.registerMediaUnderstandingProvider(buildXaiMediaUnderstandingProvider());

View File

@@ -1,4 +1,6 @@
// Xai plugin module implements model id behavior.
export const XAI_OAUTH_AUTO_MODEL_ID = "auto";
export function normalizeXaiModelId(id: string): string {
if (id === "grok-4.3-latest") {
return "grok-4.3";

View File

@@ -9,13 +9,20 @@ import {
EXPECTED_FALLBACKS,
} from "openclaw/plugin-sdk/provider-test-contracts";
import { describe, expect, it } from "vitest";
import { applyXaiConfig, applyXaiProviderConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
import {
applyXaiConfig,
applyXaiOAuthConfig,
applyXaiProviderConfig,
XAI_DEFAULT_MODEL_REF,
XAI_OAUTH_DEFAULT_MODEL_REF,
} from "./onboard.js";
describe("xai onboard", () => {
it("adds xAI provider with correct settings", () => {
const cfg = applyXaiConfig({});
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
expect(XAI_DEFAULT_MODEL_REF).toBe("xai/grok-4.3");
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe(XAI_DEFAULT_MODEL_REF);
});
@@ -102,6 +109,14 @@ describe("xai onboard", () => {
expect(cfg.agents?.defaults?.models?.[XAI_DEFAULT_MODEL_REF]?.alias).toBe("Grok");
});
it("persists the provider-owned auto ref for OAuth setup", () => {
const cfg = applyXaiOAuthConfig({});
expect(XAI_OAUTH_DEFAULT_MODEL_REF).toBe("xai/auto");
expect(resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model)).toBe("xai/auto");
expect(cfg.agents?.defaults?.models?.["xai/auto"]?.alias).toBe("Grok");
});
it("preserves existing model fallbacks", () => {
const cfg = applyXaiConfig(createConfigWithFallbacks());
expect(resolveAgentModelFallbackValues(cfg.agents?.defaults?.model)).toEqual([

View File

@@ -9,21 +9,28 @@ import {
XAI_BASE_URL,
XAI_DEFAULT_MODEL_ID,
} from "./model-definitions.js";
import { XAI_OAUTH_AUTO_MODEL_ID } from "./model-id.js";
export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`;
// OAuth resolves this stable ref against xAI's authenticated catalog and
// remote default setting. API-key setup stays on the pinned default above.
export const XAI_OAUTH_DEFAULT_MODEL_REF = `xai/${XAI_OAUTH_AUTO_MODEL_ID}`;
const xaiPresetAppliers = createModelCatalogPresetAppliers<
["openai-completions" | "openai-responses"]
>({
primaryModelRef: XAI_DEFAULT_MODEL_REF,
resolveParams: (_cfg: OpenClawConfig, api) => ({
providerId: "xai",
api,
baseUrl: XAI_BASE_URL,
catalogModels: buildXaiCatalogModels(),
aliases: [{ modelRef: XAI_DEFAULT_MODEL_REF, alias: "Grok" }],
}),
});
function createXaiPresetAppliers(primaryModelRef: string) {
return createModelCatalogPresetAppliers<["openai-completions" | "openai-responses"]>({
primaryModelRef,
resolveParams: (_cfg: OpenClawConfig, api) => ({
providerId: "xai",
api,
baseUrl: XAI_BASE_URL,
catalogModels: buildXaiCatalogModels(),
aliases: [{ modelRef: primaryModelRef, alias: "Grok" }],
}),
});
}
const xaiPresetAppliers = createXaiPresetAppliers(XAI_DEFAULT_MODEL_REF);
const xaiOAuthPresetAppliers = createXaiPresetAppliers(XAI_OAUTH_DEFAULT_MODEL_REF);
function pruneRetiredXaiBuiltinModels(cfg: OpenClawConfig): OpenClawConfig {
const provider = cfg.models?.providers?.xai;
@@ -59,3 +66,7 @@ export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig {
return xaiPresetAppliers.applyConfig(pruneRetiredXaiBuiltinModels(cfg), "openai-responses");
}
export function applyXaiOAuthConfig(cfg: OpenClawConfig): OpenClawConfig {
return xaiOAuthPresetAppliers.applyConfig(pruneRetiredXaiBuiltinModels(cfg), "openai-responses");
}

View File

@@ -1,6 +1,7 @@
// Xai provider module implements model/runtime integration.
import {
buildLiveModelProviderConfig,
getCachedLiveProviderModelRows,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import type {
@@ -15,11 +16,13 @@ import {
XAI_IMAGE_MODELS,
XAI_DEFAULT_MAX_TOKENS,
} from "./model-definitions.js";
import { XAI_OAUTH_AUTO_MODEL_ID } from "./model-id.js";
const PROVIDER_ID = "xai";
const XAI_MODELS_ENDPOINT = `${XAI_BASE_URL}/models`;
const XAI_GROK_OAUTH_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
export const XAI_GROK_OAUTH_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
const XAI_GROK_OAUTH_MODELS_ENDPOINT = `${XAI_GROK_OAUTH_BASE_URL}/models`;
const XAI_GROK_OAUTH_SETTINGS_ENDPOINT = `${XAI_GROK_OAUTH_BASE_URL}/settings`;
const XAI_MODELS_CACHE_TTL_MS = 60_000;
const XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS = 60_000;
// Composer emits replayable Responses reasoning, but the OAuth catalog omits that capability.
@@ -51,6 +54,100 @@ function buildXaiOAuthFallbackProvider(): ModelProviderConfig {
};
}
function normalizeXaiOAuthModelSelector(value: string): string {
return value.trim().toLowerCase().replace(/[._]+/g, "-");
}
function resolveXaiOAuthAutoTarget(
models: readonly ModelDefinitionConfig[],
preferredModelId: string | undefined,
): ModelDefinitionConfig | undefined {
const candidates = models.filter((model) => model.id !== XAI_OAUTH_AUTO_MODEL_ID);
if (preferredModelId) {
const exact = candidates.find((model) => model.id === preferredModelId);
if (exact) {
return exact;
}
const selector = normalizeXaiOAuthModelSelector(preferredModelId);
const normalizedMatches = candidates.filter(
(model) => normalizeXaiOAuthModelSelector(model.id) === selector,
);
if (normalizedMatches.length === 1) {
return normalizedMatches[0];
}
}
// Match Grok Build's fallback when its remote default is absent or stale:
// use the first auth-visible model returned by the provider catalog.
return candidates[0];
}
function withXaiOAuthAutoModel(
provider: ModelProviderConfig,
preferredModelId: string | undefined,
): ModelProviderConfig {
const target = resolveXaiOAuthAutoTarget(provider.models, preferredModelId);
if (!target) {
return provider;
}
return {
...provider,
models: [
{
...target,
id: XAI_OAUTH_AUTO_MODEL_ID,
params: {
...target.params,
canonicalModelId: target.id,
},
},
...provider.models.filter((model) => model.id !== XAI_OAUTH_AUTO_MODEL_ID),
],
};
}
function readXaiOAuthDefaultModelId(value: unknown): string | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
return readLiveModelString(value, "default_model");
}
async function fetchXaiOAuthDefaultModelId(params: {
discoveryApiKey: string;
fetchGuard?: LiveModelCatalogFetchGuard;
signal?: AbortSignal;
}): Promise<string | undefined> {
try {
const rows = await getCachedLiveProviderModelRows({
providerId: PROVIDER_ID,
endpoint: XAI_GROK_OAUTH_SETTINGS_ENDPOINT,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS,
auditContext: "xai-grok-oauth-settings-discovery",
cacheKeyParts: [
PROVIDER_ID,
"grok-oauth-settings",
XAI_GROK_OAUTH_SETTINGS_ENDPOINT,
params.discoveryApiKey,
],
readRows: (body) => {
if (!body || typeof body !== "object" || Array.isArray(body)) {
throw new Error("xAI OAuth settings response must be an object");
}
return [body];
},
shouldCacheRows: (candidateRows) =>
readXaiOAuthDefaultModelId(candidateRows[0]) !== undefined,
});
return readXaiOAuthDefaultModelId(rows[0]);
} catch {
// Remote settings are advisory. Catalog order remains the provider-owned fallback.
return undefined;
}
}
export async function buildLiveXaiProvider(params: {
apiKey?: string;
discoveryApiKey?: string;
@@ -177,29 +274,33 @@ export async function buildLiveXaiOAuthProvider(params: {
signal?: AbortSignal;
}): Promise<ModelProviderConfig> {
const fallback = buildXaiOAuthFallbackProvider();
return await buildLiveModelProviderConfig({
providerId: PROVIDER_ID,
endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT,
providerConfig: {
baseUrl: fallback.baseUrl,
api: fallback.api,
auth: fallback.auth,
},
models: fallback.models,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS,
auditContext: "xai-grok-oauth-model-discovery",
cacheKeyParts: [
PROVIDER_ID,
"grok-oauth-model-rows",
XAI_GROK_OAUTH_MODELS_ENDPOINT,
params.discoveryApiKey,
],
projectRows: (rows) =>
rows
.map(buildXaiOauthModelFromLiveRow)
.filter((model): model is ModelDefinitionConfig => Boolean(model)),
});
const [provider, preferredModelId] = await Promise.all([
buildLiveModelProviderConfig({
providerId: PROVIDER_ID,
endpoint: XAI_GROK_OAUTH_MODELS_ENDPOINT,
providerConfig: {
baseUrl: fallback.baseUrl,
api: fallback.api,
auth: fallback.auth,
},
models: fallback.models,
discoveryApiKey: params.discoveryApiKey,
fetchGuard: params.fetchGuard,
signal: params.signal,
ttlMs: XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS,
auditContext: "xai-grok-oauth-model-discovery",
cacheKeyParts: [
PROVIDER_ID,
"grok-oauth-model-rows",
XAI_GROK_OAUTH_MODELS_ENDPOINT,
params.discoveryApiKey,
],
projectRows: (rows) =>
rows
.map(buildXaiOauthModelFromLiveRow)
.filter((model): model is ModelDefinitionConfig => Boolean(model)),
}),
fetchXaiOAuthDefaultModelId(params),
]);
return withXaiOAuthAutoModel(provider, preferredModelId);
}

View File

@@ -9,7 +9,7 @@ import {
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js";
import { normalizeXaiModelId } from "./model-id.js";
import { normalizeXaiModelId, XAI_OAUTH_AUTO_MODEL_ID } from "./model-id.js";
import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
const XAI_MODERN_MODEL_PREFIXES = ["grok-4.5", "grok-build-0.1", "grok-4.3", "grok-4.20"] as const;
@@ -47,3 +47,15 @@ export function resolveXaiForwardCompatModel(params: {
} as ProviderRuntimeModel),
);
}
export function normalizeXaiResolvedModel(model: ProviderRuntimeModel): ProviderRuntimeModel {
const canonicalModelId =
typeof model.params?.canonicalModelId === "string"
? model.params.canonicalModelId.trim()
: undefined;
const resolved =
model.id === XAI_OAUTH_AUTO_MODEL_ID && canonicalModelId
? { ...model, id: canonicalModelId }
: model;
return applyXaiRuntimeModelCompat(resolved);
}

View File

@@ -148,6 +148,70 @@ async function captureXaiResponsesPayloadWithThinking(
}
describe("xai stream wrappers", () => {
it("adds the Grok OAuth proxy request contract", () => {
let capturedHeaders: Record<string, string> | undefined;
const baseStreamFn: StreamFn = (_model, _context, options) => {
capturedHeaders = options?.headers;
return {} as ReturnType<StreamFn>;
};
const wrapped = wrapXaiProviderStream(
{
streamFn: baseStreamFn,
extraParams: { tool_stream: false },
} as never,
{ clientVersion: "2026.7.2" },
);
void wrapped?.(
{
api: "openai-responses",
provider: "xai",
id: "grok-4.5",
baseUrl: "https://cli-chat-proxy.grok.com/v1",
} as Model<"openai-responses">,
{ messages: [] } as Context,
{ headers: { "X-XAI-Token-Auth": "operator-value", "X-Existing": "kept" } },
);
expect(capturedHeaders).toEqual({
"x-existing": "kept",
"x-grok-client-version": "2026.7.2",
"x-grok-model-override": "grok-4.5",
"x-xai-token-auth": "xai-grok-cli",
});
});
it.each([
["the public API-key endpoint", "xai", "https://api.x.ai/v1"],
["a different provider", "other", "https://cli-chat-proxy.grok.com/v1"],
])("does not add Grok OAuth headers for %s", (_label, provider, baseUrl) => {
let capturedHeaders: Record<string, string> | undefined;
const baseStreamFn: StreamFn = (_model, _context, options) => {
capturedHeaders = options?.headers;
return {} as ReturnType<StreamFn>;
};
const wrapped = wrapXaiProviderStream(
{
streamFn: baseStreamFn,
extraParams: { tool_stream: false },
} as never,
{ clientVersion: "2026.7.2" },
);
void wrapped?.(
{
api: "openai-responses",
provider,
id: "grok-4.5",
baseUrl,
} as Model<"openai-responses">,
{ messages: [] } as Context,
{ headers: { "X-Existing": "kept" } },
);
expect(capturedHeaders).toEqual({ "X-Existing": "kept" });
});
it("rewrites supported Grok models to fast variants when fast mode is enabled", () => {
expect(captureWrappedModelId({ modelId: "grok-3", fastMode: true })).toBe("grok-3-fast");
expect(

View File

@@ -8,6 +8,7 @@ import {
createPlainTextToolCallCompatWrapper,
createToolStreamWrapper,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { XAI_GROK_OAUTH_BASE_URL } from "./provider-catalog.js";
import { isXaiProviderId } from "./provider-id.js";
const XAI_FAST_MODEL_IDS = new Map<string, string>([
@@ -18,6 +19,36 @@ const XAI_FAST_MODEL_IDS = new Map<string, string>([
]);
type DynamicFastMode = boolean | (() => boolean | undefined);
function isXaiGrokOAuthProxyModel(model: Parameters<StreamFn>[0]): boolean {
return (
isXaiProviderId(model.provider) &&
model.baseUrl?.trim().replace(/\/+$/u, "") === XAI_GROK_OAUTH_BASE_URL
);
}
function createXaiGrokOAuthHeadersWrapper(
baseStreamFn: StreamFn | undefined,
clientVersion: string | undefined,
): StreamFn {
const underlying = baseStreamFn ?? streamSimple;
const normalizedClientVersion = clientVersion?.trim();
return (model, context, options) => {
if (!normalizedClientVersion || !isXaiGrokOAuthProxyModel(model)) {
return underlying(model, context, options);
}
const headers = new Headers(options?.headers);
// The Grok OAuth proxy requires its CLI identity and a concrete catalog model.
// Keep these proxy-only so ordinary xAI API-key traffic retains its public contract.
headers.set("X-XAI-Token-Auth", "xai-grok-cli");
headers.set("x-grok-client-version", normalizedClientVersion);
headers.set("x-grok-model-override", model.id);
return underlying(model, context, {
...options,
headers: Object.fromEntries(headers.entries()),
});
};
}
function resolveXaiFastModelId(modelId: unknown): string | undefined {
if (typeof modelId !== "string") {
return undefined;
@@ -270,11 +301,15 @@ function hasXaiFastModeParam(extraParams: Record<string, unknown> | undefined):
);
}
export function wrapXaiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn | undefined {
export function wrapXaiProviderStream(
ctx: ProviderWrapStreamFnContext,
runtime?: { clientVersion?: string },
): StreamFn | undefined {
const extraParams = ctx.extraParams;
const toolStreamEnabled = extraParams?.tool_stream !== false;
return composeProviderStreamWrappers(
ctx.streamFn,
(streamFn) => createXaiGrokOAuthHeadersWrapper(streamFn, runtime?.clientVersion),
createXaiToolPayloadCompatibilityWrapper,
hasXaiFastModeParam(extraParams) &&
((streamFn) => createXaiFastModeWrapper(streamFn, () => resolveXaiFastMode(extraParams))),

View File

@@ -429,6 +429,11 @@ describe("xAI OAuth", () => {
accountId: "acct-1",
access: expect.any(String),
});
expect(result.defaultModel).toBe("xai/auto");
expect(result.configPatch?.agents?.defaults?.model).toEqual({
primary: "xai/auto",
});
expect(result.configPatch?.agents?.defaults?.models?.["xai/auto"]?.alias).toBe("Grok");
expect(progress.update).toHaveBeenCalledWith("Waiting for xAI device authorization...");
expect(progress.stop).toHaveBeenCalledWith("xAI OAuth complete");
});

View File

@@ -14,7 +14,7 @@ import {
} from "openclaw/plugin-sdk/provider-auth";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { applyXaiConfig, XAI_DEFAULT_MODEL_REF } from "./onboard.js";
import { applyXaiOAuthConfig, XAI_OAUTH_DEFAULT_MODEL_REF } from "./onboard.js";
import { xaiUserAgent } from "./src/xai-user-agent.js";
const PROVIDER_ID = "xai";
@@ -651,14 +651,14 @@ async function loginXaiDeviceCode(ctx: ProviderAuthContext): Promise<ProviderAut
progress.stop("xAI OAuth complete");
return buildOauthProviderAuthResult({
providerId: PROVIDER_ID,
defaultModel: XAI_DEFAULT_MODEL_REF,
defaultModel: XAI_OAUTH_DEFAULT_MODEL_REF,
access: tokens.accessToken,
refresh: tokens.refreshToken,
expires: tokens.expires,
email: identity.email,
displayName: identity.displayName,
profileName: identity.email ?? identity.accountId,
configPatch: applyXaiConfig(ctx.config),
configPatch: applyXaiOAuthConfig(ctx.config),
credentialExtra: {
tokenEndpoint: discovery.tokenEndpoint,
deviceAuthorizationEndpoint: discovery.deviceAuthorizationEndpoint,