mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 14:48:55 +02:00
fix(xai): refresh oauth and model catalog
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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) ?? "";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, "&")
|
||||
|
||||
Reference in New Issue
Block a user