diff --git a/CHANGELOG.md b/CHANGELOG.md index 6683b9a8b56..58e5c966fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/providers/xai.md b/docs/providers/xai.md index e4c53adba6e..728d9211af0 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -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. -`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. ## OpenClaw feature coverage diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index 0b43a996806..e777c062e04 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -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([ + "grok-4.3", + "grok-4.20-beta-latest-reasoning", + "grok-4.20-beta-latest-non-reasoning", +]); + +const XAI_RETIRED_BUILTIN_MODEL_IDS = new Set( + 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) { diff --git a/extensions/xai/onboard.test.ts b/extensions/xai/onboard.test.ts index df5d1e3205c..20684565683 100644 --- a/extensions/xai/onboard.test.ts +++ b/extensions/xai/onboard.test.ts @@ -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", () => { diff --git a/extensions/xai/onboard.ts b/extensions/xai/onboard.ts index 5d71bdc56a5..cb6da4a458c 100644 --- a/extensions/xai/onboard.ts +++ b/extensions/xai/onboard.ts @@ -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"); } diff --git a/extensions/xai/provider-models.ts b/extensions/xai/provider-models.ts index e4b9a414633..2258998db45 100644 --- a/extensions/xai/provider-models.ts +++ b/extensions/xai/provider-models.ts @@ -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) ?? ""; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 6e46a1576a7..a541ccd93f8 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -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); }); diff --git a/extensions/xai/xai-oauth.test.ts b/extensions/xai/xai-oauth.test.ts index cd56ef99980..7388be164d2 100644 --- a/extensions/xai/xai-oauth.test.ts +++ b/extensions/xai/xai-oauth.test.ts @@ -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", diff --git a/src/plugin-sdk/provider-auth-runtime.test.ts b/src/plugin-sdk/provider-auth-runtime.test.ts index cc0ef4a38ce..b7f87b19fda 100644 --- a/src/plugin-sdk/provider-auth-runtime.test.ts +++ b/src/plugin-sdk/provider-auth-runtime.test.ts @@ -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 { + 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" }); + }); }); diff --git a/src/plugin-sdk/provider-auth-runtime.ts b/src/plugin-sdk/provider-auth-runtime.ts index a220e2db125..1dac0a4a08e 100644 --- a/src/plugin-sdk/provider-auth-runtime.ts +++ b/src/plugin-sdk/provider-auth-runtime.ts @@ -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, "&")