fix(xai): refresh oauth and model catalog

This commit is contained in:
Peter Steinberger
2026-05-16 20:22:03 +01:00
parent 7d09ff89ee
commit 6e4cc222cb
10 changed files with 225 additions and 40 deletions

View File

@@ -22,6 +22,8 @@ Docs: https://docs.openclaw.ai
- CLI/config: add `--dry-run` support to `openclaw config unset`, with `--json` output and allow-exec validation parity with `config set`/`config patch` dry-run handling. (#81895) Thanks @giodl73-repo.
- Memory-core: retry disabled dreaming cron cleanup until cron is available after startup, so persisted managed dreaming jobs are removed after restart. Fixes #82383. (#82389) Thanks @neeravmakwana.
- Providers/xAI: keep retired Grok 3, Grok 4 Fast, Grok 4.1 Fast, and Grok Code slugs out of model pickers while preserving compatibility resolution for existing configs.
- Providers/OAuth: let browser-hosted identity provider pages read successful localhost callback responses, preventing xAI Grok OAuth from showing a false connection failure after OpenClaw completes login.
- Gateway/diagnostics: redact credential-bearing gateway target URLs and client diagnostics while preserving raw connection URLs for programmatic use, so connect-failure logs no longer surface embedded tokens.
- Gateway/auth: honor `OPENCLAW_GATEWAY_TOKEN` as the remote interactive fallback when no remote token is configured, keeping remote TUI setup aligned with documented auth precedence.
- Logs: redact raw Basic auth and named security headers from `logs.tail` output before returning lines to read-scoped clients. Fixes #66832. Thanks @Magicray1217.

View File

@@ -50,24 +50,21 @@ and, by default, `x_search` through an operator xAI Responses proxy.
## Built-in catalog
OpenClaw includes these xAI model families out of the box:
OpenClaw includes the current xAI chat models out of the box, ordered newest
first in model pickers:
| Family | Model ids |
| -------------- | ------------------------------------------------------------------------ |
| Grok 3 | `grok-3`, `grok-3-fast`, `grok-3-mini`, `grok-3-mini-fast` |
| Grok 4.3 | `grok-4.3` |
| Grok 4 | `grok-4`, `grok-4-0709` |
| Grok 4 Fast | `grok-4-fast`, `grok-4-fast-non-reasoning` |
| Grok 4.1 Fast | `grok-4-1-fast`, `grok-4-1-fast-non-reasoning` |
| Grok 4.20 Beta | `grok-4.20-beta-latest-reasoning`, `grok-4.20-beta-latest-non-reasoning` |
| Grok Code | `grok-code-fast-1` |
The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when
they follow the same API shape.
The plugin still forward-resolves older Grok 3, Grok 4, Grok 4 Fast, Grok 4.1
Fast, and Grok Code slugs for existing configs, but OpenClaw no longer shows
those retired upstream slugs in the selectable catalog.
<Tip>
`grok-4.3`, `grok-4-fast`, `grok-4-1-fast`, and the `grok-4.20-beta-*`
variants are the current image-capable Grok refs in the bundled catalog.
Use `grok-4.3` for new chat and coding workloads unless you explicitly need a
Grok 4.20 beta alias.
</Tip>
## OpenClaw feature coverage

View File

@@ -41,8 +41,8 @@ const XAI_FAST_COST = {
} satisfies XaiCost;
const XAI_GROK_420_COST = {
input: 2,
output: 6,
input: 1.25,
output: 2.5,
cacheRead: 0.2,
cacheWrite: 0,
} satisfies XaiCost;
@@ -190,6 +190,25 @@ const XAI_MODEL_CATALOG = [
},
] as const satisfies readonly XaiCatalogEntry[];
const XAI_SELECTABLE_MODEL_IDS = new Set<string>([
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
const XAI_RETIRED_BUILTIN_MODEL_IDS = new Set<string>(
XAI_MODEL_CATALOG.map((entry) => entry.id).filter((id) => !XAI_SELECTABLE_MODEL_IDS.has(id)),
);
function normalizeXaiCatalogModelId(modelId: string): string {
const lower = normalizeOptionalLowercaseString(modelId) ?? "";
return lower.startsWith("xai/") ? lower.slice("xai/".length) : lower;
}
export function isRetiredXaiBuiltinModelId(modelId: string): boolean {
return XAI_RETIRED_BUILTIN_MODEL_IDS.has(normalizeXaiCatalogModelId(modelId));
}
function toModelDefinition(entry: XaiCatalogEntry): ModelDefinitionConfig {
return {
id: entry.id,
@@ -217,7 +236,9 @@ export function buildXaiModelDefinition(): ModelDefinitionConfig {
}
export function buildXaiCatalogModels(): ModelDefinitionConfig[] {
return XAI_MODEL_CATALOG.map((entry) => toModelDefinition(entry));
return XAI_MODEL_CATALOG.filter((entry) => XAI_SELECTABLE_MODEL_IDS.has(entry.id)).map((entry) =>
toModelDefinition(entry),
);
}
export function resolveXaiCatalogEntry(modelId: string) {

View File

@@ -19,25 +19,56 @@ describe("xai onboard", () => {
});
it("merges xAI models and keeps existing provider overrides", () => {
const cfg = applyXaiProviderConfig(
createLegacyProviderConfig({
providerId: "xai",
api: "anthropic-messages",
modelId: "custom-model",
modelName: "Custom",
}),
const legacy = createLegacyProviderConfig({
providerId: "xai",
api: "anthropic-messages",
modelId: "custom-model",
modelName: "Custom",
});
legacy.models!.providers!.xai!.models.push(
{
id: "grok-3",
name: "Grok 3",
reasoning: false,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
{
id: "grok-code-fast-1",
name: "Grok Code Fast 1",
reasoning: true,
input: ["text"],
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000,
maxTokens: 100,
},
);
const cfg = applyXaiProviderConfig(legacy);
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
expect(cfg.models?.providers?.xai?.apiKey).toBe("old-key");
const modelIds = cfg.models?.providers?.xai?.models.map((m) => m.id) ?? [];
expect(modelIds).toContain("custom-model");
expect(modelIds).toContain("grok-4.3");
expect(modelIds).toContain("grok-4");
expect(modelIds).toContain("grok-4-1-fast");
expect(modelIds).toContain("grok-4.20-beta-latest-reasoning");
expect(modelIds).toContain("grok-code-fast-1");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual([
"custom-model",
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
});
it("publishes current xAI models newest first for fresh setup", () => {
const cfg = applyXaiProviderConfig({});
expect(cfg.models?.providers?.xai?.baseUrl).toBe("https://api.x.ai/v1");
expect(cfg.models?.providers?.xai?.api).toBe("openai-responses");
expect(cfg.models?.providers?.xai?.models.map((m) => m.id)).toEqual([
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
});
it("adds expected alias for the default model", () => {

View File

@@ -1,13 +1,13 @@
import {
createDefaultModelsPresetAppliers,
createModelCatalogPresetAppliers,
type OpenClawConfig,
} from "openclaw/plugin-sdk/provider-onboard";
import { XAI_BASE_URL, XAI_DEFAULT_MODEL_ID } from "./model-definitions.js";
import { buildXaiCatalogModels } from "./model-definitions.js";
import { buildXaiCatalogModels, isRetiredXaiBuiltinModelId } from "./model-definitions.js";
export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`;
const xaiPresetAppliers = createDefaultModelsPresetAppliers<
const xaiPresetAppliers = createModelCatalogPresetAppliers<
["openai-completions" | "openai-responses"]
>({
primaryModelRef: XAI_DEFAULT_MODEL_REF,
@@ -15,16 +15,42 @@ const xaiPresetAppliers = createDefaultModelsPresetAppliers<
providerId: "xai",
api,
baseUrl: XAI_BASE_URL,
defaultModels: buildXaiCatalogModels(),
defaultModelId: XAI_DEFAULT_MODEL_ID,
catalogModels: buildXaiCatalogModels(),
aliases: [{ modelRef: XAI_DEFAULT_MODEL_REF, alias: "Grok" }],
}),
});
function pruneRetiredXaiBuiltinModels(cfg: OpenClawConfig): OpenClawConfig {
const provider = cfg.models?.providers?.xai;
if (!provider || !Array.isArray(provider.models)) {
return cfg;
}
const models = provider.models.filter((model) => !isRetiredXaiBuiltinModelId(model.id));
if (models.length === provider.models.length) {
return cfg;
}
return {
...cfg,
models: {
...cfg.models,
providers: {
...cfg.models?.providers,
xai: {
...provider,
models,
},
},
},
};
}
export function applyXaiProviderConfig(cfg: OpenClawConfig): OpenClawConfig {
return xaiPresetAppliers.applyProviderConfig(cfg, "openai-responses");
return xaiPresetAppliers.applyProviderConfig(
pruneRetiredXaiBuiltinModels(cfg),
"openai-responses",
);
}
export function applyXaiConfig(cfg: OpenClawConfig): OpenClawConfig {
return xaiPresetAppliers.applyConfig(cfg, "openai-responses");
return xaiPresetAppliers.applyConfig(pruneRetiredXaiBuiltinModels(cfg), "openai-responses");
}

View File

@@ -7,7 +7,7 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coe
import { resolveXaiCatalogEntry, XAI_BASE_URL } from "./model-definitions.js";
import { applyXaiRuntimeModelCompat } from "./runtime-model-compat.js";
const XAI_MODERN_MODEL_PREFIXES = ["grok-3", "grok-4", "grok-code-fast"] as const;
const XAI_MODERN_MODEL_PREFIXES = ["grok-4.3", "grok-4.20"] as const;
export function isModernXaiModel(modelId: string): boolean {
const lower = normalizeOptionalLowercaseString(modelId) ?? "";

View File

@@ -3,7 +3,7 @@ import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runt
import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime-env";
import { withEnv, withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveXaiCatalogEntry } from "./model-definitions.js";
import { buildXaiCatalogModels, resolveXaiCatalogEntry } from "./model-definitions.js";
import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js";
import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js";
import { wrapXaiWebSearchError } from "./src/web-search-shared.js";
@@ -574,6 +574,14 @@ describe("xai web search response parsing", () => {
});
describe("xai provider models", () => {
it("publishes only current selectable chat models newest first", () => {
expect(buildXaiCatalogModels().map((model) => model.id)).toEqual([
"grok-4.3",
"grok-4.20-beta-latest-reasoning",
"grok-4.20-beta-latest-non-reasoning",
]);
});
it("publishes Grok 4.3 as the default chat model", () => {
expectCatalogEntry("grok-4.3", {
id: "grok-4.3",
@@ -585,7 +593,7 @@ describe("xai provider models", () => {
});
});
it("publishes the newer Grok fast and code models in the bundled catalog", () => {
it("keeps retired Grok fast and code slugs resolving for compatibility", () => {
expectCatalogEntry("grok-4-1-fast", {
id: "grok-4-1-fast",
reasoning: true,
@@ -648,8 +656,8 @@ describe("xai provider models", () => {
it("marks current Grok families as modern while excluding multi-agent ids", () => {
expect(isModernXaiModel("grok-4.3")).toBe(true);
expect(isModernXaiModel("grok-4.20-beta-latest-reasoning")).toBe(true);
expect(isModernXaiModel("grok-code-fast-1")).toBe(true);
expect(isModernXaiModel("grok-3-mini-fast")).toBe(true);
expect(isModernXaiModel("grok-code-fast-1")).toBe(false);
expect(isModernXaiModel("grok-3-mini-fast")).toBe(false);
expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false);
});

View File

@@ -27,7 +27,7 @@ describe("xAI OAuth", () => {
expect(isTrustedXaiOAuthEndpoint("not a url")).toBe(false);
});
it("builds the Hermes-compatible authorize URL for OpenClaw", () => {
it("builds the xAI authorize URL for OpenClaw", () => {
const url = new URL(
buildXaiOAuthAuthorizeUrl({
authorizationEndpoint: "https://auth.x.ai/oauth2/authorize",

View File

@@ -1,6 +1,23 @@
import { createServer } from "node:net";
import { describe, expect, it } from "vitest";
import * as providerAuthRuntime from "./provider-auth-runtime.js";
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to allocate a local port")));
return;
}
const { port } = address;
server.close((err) => (err ? reject(err) : resolve(port)));
});
});
}
describe("plugin-sdk provider-auth-runtime", () => {
it("exports the runtime-ready auth helper", () => {
expect(providerAuthRuntime.getRuntimeAuthForModel).toBeTypeOf("function");
@@ -25,4 +42,42 @@ describe("plugin-sdk provider-auth-runtime", () => {
error: "Paste the full redirect URL, not just the code.",
});
});
it("allows browser IdP pages to probe the localhost callback with CORS", async () => {
const port = await getFreePort();
const callback = providerAuthRuntime.waitForLocalOAuthCallback({
expectedState: "state-1",
timeoutMs: 5_000,
port,
callbackPath: "/callback",
redirectUri: `http://127.0.0.1:${port}/callback`,
hostname: "127.0.0.1",
successTitle: "OAuth complete",
});
const preflight = await fetch(`http://127.0.0.1:${port}/callback`, {
method: "OPTIONS",
headers: {
Origin: "https://auth.x.ai",
"Access-Control-Request-Method": "GET",
"Access-Control-Request-Headers": "content-type",
"Access-Control-Request-Private-Network": "true",
},
});
expect(preflight.status).toBe(204);
expect(preflight.headers.get("access-control-allow-origin")).toBe("https://auth.x.ai");
expect(preflight.headers.get("access-control-allow-methods")).toContain("GET");
expect(preflight.headers.get("access-control-allow-private-network")).toBe("true");
const response = await fetch(`http://127.0.0.1:${port}/callback?code=code-1&state=state-1`, {
headers: {
Origin: "https://auth.x.ai",
},
});
expect(response.status).toBe(200);
expect(response.headers.get("access-control-allow-origin")).toBe("https://auth.x.ai");
await expect(callback).resolves.toEqual({ code: "code-1", state: "state-1" });
});
});

View File

@@ -74,6 +74,7 @@ export async function waitForLocalOAuthCallback(params: {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
applyOAuthCallbackCorsHeaders(req, res);
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${params.port}`);
if (requestUrl.pathname !== params.callbackPath) {
res.statusCode = 404;
@@ -82,6 +83,19 @@ export async function waitForLocalOAuthCallback(params: {
return;
}
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
return;
}
if (req.method !== "GET") {
res.statusCode = 405;
res.setHeader("Content-Type", "text/plain");
res.end("Method not allowed");
return;
}
const error = requestUrl.searchParams.get("error");
const code = requestUrl.searchParams.get("code")?.trim();
const state = requestUrl.searchParams.get("state")?.trim();
@@ -160,6 +174,37 @@ export async function waitForLocalOAuthCallback(params: {
});
}
function applyOAuthCallbackCorsHeaders(
req: import("node:http").IncomingMessage,
res: import("node:http").ServerResponse,
): void {
const origin = req.headers.origin;
if (typeof origin === "string" && isHttpOrigin(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers");
}
const requestedHeaders = req.headers["access-control-request-headers"];
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader(
"Access-Control-Allow-Headers",
typeof requestedHeaders === "string" && requestedHeaders.trim().length > 0
? requestedHeaders
: "content-type",
);
res.setHeader("Access-Control-Allow-Private-Network", "true");
res.setHeader("Access-Control-Max-Age", "600");
}
function isHttpOrigin(value: string): boolean {
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && url.origin === value;
} catch {
return false;
}
}
function escapeHtmlText(value: string): string {
return value
.replace(/&/g, "&amp;")