From 916f496b5190c34e8514a11b260ea23409775872 Mon Sep 17 00:00:00 2001 From: Jaaneek <25470423+Jaaneek@users.noreply.github.com> Date: Fri, 20 Mar 2026 19:28:30 +0000 Subject: [PATCH] Add Grok 4.20 reasoning and non-reasoning to xAI model catalog (#50772) Merged via squash. Prepared head SHA: 095e645ea58b2259b25c923aeaf11bbcb2990c8f Co-authored-by: Jaaneek <25470423+Jaaneek@users.noreply.github.com> Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/providers/xai.md | 3 +- extensions/xai/model-definitions.ts | 8 ++-- extensions/xai/provider-models.test.ts | 19 +++++++-- extensions/xai/src/web-search-shared.ts | 3 +- extensions/xai/web-search.test.ts | 13 +++++++ src/agents/model-id-normalization.test.ts | 18 +++++++++ src/agents/model-id-normalization.ts | 10 +++++ src/agents/model-selection.test.ts | 9 +++++ src/agents/model-selection.ts | 5 ++- src/agents/models-config.providers.ts | 4 +- src/agents/tools/web-search.test.ts | 9 +++++ src/auto-reply/reply/model-selection.test.ts | 41 ++++++++++++++++++++ src/auto-reply/reply/model-selection.ts | 15 ++++--- src/gateway/model-pricing-cache.test.ts | 19 +++++++-- src/gateway/model-pricing-cache.ts | 5 ++- src/plugin-sdk/provider-models.ts | 1 + 17 files changed, 161 insertions(+), 22 deletions(-) create mode 100644 src/agents/model-id-normalization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 10abb592b24..08405393027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,7 @@ Docs: https://docs.openclaw.ai - Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant. - Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant. - Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant. +- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek ### Fixes diff --git a/docs/providers/xai.md b/docs/providers/xai.md index ec491735e50..271eae0bc57 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -34,8 +34,7 @@ OpenClaw now includes these xAI model families out of the box: - `grok-4`, `grok-4-0709` - `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning` - `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning` -- `grok-4.20-experimental-beta-0304-reasoning` -- `grok-4.20-experimental-beta-0304-non-reasoning` +- `grok-4.20-reasoning`, `grok-4.20-non-reasoning` - `grok-code-fast-1` The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts index 87d18484264..a925f7848ca 100644 --- a/extensions/xai/model-definitions.ts +++ b/extensions/xai/model-definitions.ts @@ -59,14 +59,14 @@ const XAI_MODEL_CATALOG = [ contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, { - id: "grok-4.20-experimental-beta-0304-reasoning", - name: "Grok 4.20 Experimental Beta 0304 (Reasoning)", + id: "grok-4.20-reasoning", + name: "Grok 4.20 (Reasoning)", reasoning: true, contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, { - id: "grok-4.20-experimental-beta-0304-non-reasoning", - name: "Grok 4.20 Experimental Beta 0304 (Non-Reasoning)", + id: "grok-4.20-non-reasoning", + name: "Grok 4.20 (Non-Reasoning)", reasoning: false, contextWindow: XAI_LARGE_CONTEXT_WINDOW, }, diff --git a/extensions/xai/provider-models.test.ts b/extensions/xai/provider-models.test.ts index 175209f4975..d0d025a852a 100644 --- a/extensions/xai/provider-models.test.ts +++ b/extensions/xai/provider-models.test.ts @@ -16,8 +16,21 @@ describe("xai provider models", () => { }); }); + it("publishes Grok 4.20 reasoning and non-reasoning models", () => { + expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({ + id: "grok-4.20-reasoning", + reasoning: true, + contextWindow: 2_000_000, + }); + expect(resolveXaiCatalogEntry("grok-4.20-non-reasoning")).toMatchObject({ + id: "grok-4.20-non-reasoning", + reasoning: false, + contextWindow: 2_000_000, + }); + }); + it("marks current Grok families as modern while excluding multi-agent ids", () => { - expect(isModernXaiModel("grok-4.20-experimental-beta-0304-reasoning")).toBe(true); + expect(isModernXaiModel("grok-4.20-reasoning")).toBe(true); expect(isModernXaiModel("grok-code-fast-1")).toBe(true); expect(isModernXaiModel("grok-3-mini-fast")).toBe(false); expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false); @@ -40,7 +53,7 @@ describe("xai provider models", () => { providerId: "xai", ctx: { provider: "xai", - modelId: "grok-4.20-experimental-beta-0304-reasoning", + modelId: "grok-4.20-reasoning", modelRegistry: { find: () => null } as never, providerConfig: { api: "openai-completions", @@ -59,7 +72,7 @@ describe("xai provider models", () => { }); expect(grok420).toMatchObject({ provider: "xai", - id: "grok-4.20-experimental-beta-0304-reasoning", + id: "grok-4.20-reasoning", api: "openai-completions", baseUrl: "https://api.x.ai/v1", reasoning: true, diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts index 47616bcf13c..85ea11aa49d 100644 --- a/extensions/xai/src/web-search-shared.ts +++ b/extensions/xai/src/web-search-shared.ts @@ -1,3 +1,4 @@ +import { normalizeXaiModelId } from "openclaw/plugin-sdk/provider-models"; import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search"; export const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses"; @@ -79,7 +80,7 @@ export function resolveXaiSearchConfig(searchConfig?: Record): export function resolveXaiWebSearchModel(searchConfig?: Record): string { const config = resolveXaiSearchConfig(searchConfig); return typeof config.model === "string" && config.model.trim() - ? config.model.trim() + ? normalizeXaiModelId(config.model.trim()) : XAI_DEFAULT_WEB_SEARCH_MODEL; } diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index 29433ec7efa..a6dfff40633 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -44,6 +44,19 @@ describe("xai web search config resolution", () => { ); }); + it("normalizes deprecated grok 4.20 beta model ids to GA ids", () => { + expect( + resolveXaiWebSearchModel({ + grok: { model: "grok-4.20-experimental-beta-0304-reasoning" }, + }), + ).toBe("grok-4.20-reasoning"); + expect( + resolveXaiWebSearchModel({ + grok: { model: "grok-4.20-experimental-beta-0304-non-reasoning" }, + }), + ).toBe("grok-4.20-non-reasoning"); + }); + it("defaults inlineCitations to false", () => { expect(resolveXaiInlineCitations({})).toBe(false); expect(resolveXaiInlineCitations(undefined)).toBe(false); diff --git a/src/agents/model-id-normalization.test.ts b/src/agents/model-id-normalization.test.ts new file mode 100644 index 00000000000..7ae0d1b736b --- /dev/null +++ b/src/agents/model-id-normalization.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { normalizeXaiModelId } from "./model-id-normalization.js"; + +describe("normalizeXaiModelId", () => { + it("maps deprecated grok 4.20 beta ids to GA ids", () => { + expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-reasoning")).toBe( + "grok-4.20-reasoning", + ); + expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-non-reasoning")).toBe( + "grok-4.20-non-reasoning", + ); + }); + + it("leaves current xai model ids unchanged", () => { + expect(normalizeXaiModelId("grok-4.20-reasoning")).toBe("grok-4.20-reasoning"); + expect(normalizeXaiModelId("grok-4")).toBe("grok-4"); + }); +}); diff --git a/src/agents/model-id-normalization.ts b/src/agents/model-id-normalization.ts index 9b0b27a7f01..8131c5a1d29 100644 --- a/src/agents/model-id-normalization.ts +++ b/src/agents/model-id-normalization.ts @@ -21,3 +21,13 @@ export function normalizeGoogleModelId(id: string): string { } return id; } + +export function normalizeXaiModelId(id: string): string { + if (id === "grok-4.20-experimental-beta-0304-reasoning") { + return "grok-4.20-reasoning"; + } + if (id === "grok-4.20-experimental-beta-0304-non-reasoning") { + return "grok-4.20-non-reasoning"; + } + return id; +} diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index e7d583d106f..5d81afc4970 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -194,6 +194,15 @@ describe("model-selection", () => { defaultProvider: "google", expected: { provider: "google", model: "gemini-3.1-flash-lite-preview" }, }, + { + name: "normalizes deprecated xai grok 4.20 beta ids", + variants: [ + "xai/grok-4.20-experimental-beta-0304-reasoning", + "grok-4.20-experimental-beta-0304-reasoning", + ], + defaultProvider: "xai", + expected: { provider: "xai", model: "grok-4.20-reasoning" }, + }, { name: "keeps OpenAI codex refs on the openai provider", variants: ["openai/gpt-5.3-codex", "gpt-5.3-codex"], diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index acc29a32bf9..7e654dd24f3 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -14,7 +14,7 @@ import { } from "./agent-scope.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import type { ModelCatalogEntry } from "./model-catalog.js"; -import { normalizeGoogleModelId } from "./model-id-normalization.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { splitTrailingAuthProfile } from "./model-ref-profile.js"; import { findNormalizedProviderKey, @@ -121,6 +121,9 @@ function normalizeProviderModelId(provider: string, model: string): string { if (provider === "google" || provider === "google-vertex") { return normalizeGoogleModelId(model); } + if (provider === "xai") { + return normalizeXaiModelId(model); + } // OpenRouter-native models (e.g. "openrouter/aurora-alpha") need the full // "openrouter/" as the model ID sent to the API. Models from external // providers already contain a slash (e.g. "anthropic/claude-sonnet-4-5") and diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index af9c3d6e34a..57f10206984 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -9,7 +9,7 @@ import { isRecord } from "../utils.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; -import { normalizeGoogleModelId } from "./model-id-normalization.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js"; export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js"; @@ -42,7 +42,7 @@ import { } from "./model-auth-markers.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; export { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; -export { normalizeGoogleModelId }; +export { normalizeGoogleModelId, normalizeXaiModelId }; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 9f3a6fe017c..5bb2585f3ed 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -341,6 +341,15 @@ describe("web_search grok config resolution", () => { expect(resolveGrokModel({ model: "grok-4-fast" })).toBe("grok-4-fast"); }); + it("normalizes deprecated grok 4.20 beta ids to GA ids", () => { + expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-reasoning" })).toBe( + "grok-4.20-reasoning", + ); + expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-non-reasoning" })).toBe( + "grok-4.20-non-reasoning", + ); + }); + it("falls back to default model", () => { expect(resolveGrokModel({})).toBe("grok-4-1-fast"); }); diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index e20084ed923..f31df4c0707 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -9,6 +9,8 @@ vi.mock("../../agents/model-catalog.js", () => ({ { provider: "kimi", id: "kimi-code", name: "Kimi Code" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + { provider: "xai", id: "grok-4", name: "Grok 4" }, + { provider: "xai", id: "grok-4.20-reasoning", name: "Grok 4.20 (Reasoning)" }, ]), })); @@ -263,6 +265,45 @@ describe("createModelSelectionState respects session model override", () => { expect(state.provider).toBe(defaultProvider); expect(state.model).toBe("deepseek-v3-4bit-mlx"); }); + + it("normalizes deprecated xai beta session overrides before allowlist checks", async () => { + const cfg = { + agents: { + defaults: { + model: { + primary: "xai/grok-4", + }, + models: { + "xai/grok-4": {}, + "xai/grok-4.20-experimental-beta-0304-reasoning": {}, + }, + }, + }, + } as OpenClawConfig; + const sessionKey = "agent:main:telegram:group:123:topic:99"; + const sessionEntry = makeEntry({ + providerOverride: "xai", + modelOverride: "grok-4.20-experimental-beta-0304-reasoning", + }); + const sessionStore = { [sessionKey]: sessionEntry }; + + const state = await createModelSelectionState({ + cfg, + agentCfg: cfg.agents?.defaults, + sessionEntry, + sessionStore, + sessionKey, + defaultProvider: "xai", + defaultModel: "grok-4", + provider: "xai", + model: "grok-4", + hasModelDirective: false, + }); + + expect(state.provider).toBe("xai"); + expect(state.model).toBe("grok-4.20-reasoning"); + expect(state.resetModelOverride).toBe(false); + }); }); describe("createModelSelectionState resolveDefaultReasoningLevel", () => { diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts index 33132e1f477..26ae8a9b46d 100644 --- a/src/auto-reply/reply/model-selection.ts +++ b/src/auto-reply/reply/model-selection.ts @@ -6,6 +6,7 @@ import { buildAllowedModelSet, type ModelAliasIndex, modelKey, + normalizeModelRef, normalizeProviderId, resolveModelRefFromString, resolveReasoningDefault, @@ -326,7 +327,8 @@ export async function createModelSelectionState(params: { const overrideProvider = sessionEntry.providerOverride?.trim() || defaultProvider; const overrideModel = sessionEntry.modelOverride?.trim(); if (overrideModel) { - const key = modelKey(overrideProvider, overrideModel); + const normalizedOverride = normalizeModelRef(overrideProvider, overrideModel); + const key = modelKey(normalizedOverride.provider, normalizedOverride.model); if (allowedModelKeys.size > 0 && !allowedModelKeys.has(key)) { const { updated } = applyModelOverrideToSessionEntry({ entry: sessionEntry, @@ -356,11 +358,14 @@ export async function createModelSelectionState(params: { // the regular session/parent model override behavior. const skipStoredOverride = params.hasResolvedHeartbeatModelOverride === true; if (storedOverride?.model && !skipStoredOverride) { - const candidateProvider = storedOverride.provider || defaultProvider; - const key = modelKey(candidateProvider, storedOverride.model); + const normalizedStoredOverride = normalizeModelRef( + storedOverride.provider || defaultProvider, + storedOverride.model, + ); + const key = modelKey(normalizedStoredOverride.provider, normalizedStoredOverride.model); if (allowedModelKeys.size === 0 || allowedModelKeys.has(key)) { - provider = candidateProvider; - model = storedOverride.model; + provider = normalizedStoredOverride.provider; + model = normalizedStoredOverride.model; } } diff --git a/src/gateway/model-pricing-cache.test.ts b/src/gateway/model-pricing-cache.test.ts index 8ce128d4938..159211f7e8e 100644 --- a/src/gateway/model-pricing-cache.test.ts +++ b/src/gateway/model-pricing-cache.test.ts @@ -101,7 +101,7 @@ describe("model-pricing-cache", () => { ], }, hooks: { - mappings: [{ model: "xai/grok-4" }], + mappings: [{ model: "xai/grok-4.20-experimental-beta-0304-reasoning" }], }, tools: { subagents: { model: { primary: "zai/glm-5" } }, @@ -130,7 +130,7 @@ describe("model-pricing-cache", () => { }, }, { - id: "x-ai/grok-4", + id: "x-ai/grok-4.20-experimental-beta-0304-reasoning", pricing: { prompt: "0.000002", completion: "0.00001", @@ -172,12 +172,25 @@ describe("model-pricing-cache", () => { cacheRead: 0.3, cacheWrite: 0, }); - expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4" })).toEqual({ + expect( + getCachedGatewayModelPricing({ + provider: "xai", + model: "grok-4.20-experimental-beta-0304-reasoning", + }), + ).toEqual({ input: 2, output: 10, cacheRead: 0, cacheWrite: 0, }); + expect(getCachedGatewayModelPricing({ provider: "xai", model: "grok-4.20-reasoning" })).toEqual( + { + input: 2, + output: 10, + cacheRead: 0, + cacheWrite: 0, + }, + ); expect(getCachedGatewayModelPricing({ provider: "zai", model: "glm-5" })).toEqual({ input: 1, output: 4, diff --git a/src/gateway/model-pricing-cache.ts b/src/gateway/model-pricing-cache.ts index 8a2e250f53f..ef05628d234 100644 --- a/src/gateway/model-pricing-cache.ts +++ b/src/gateway/model-pricing-cache.ts @@ -7,7 +7,7 @@ import { resolveModelRefFromString, type ModelRef, } from "../agents/model-selection.js"; -import { normalizeGoogleModelId } from "../agents/models-config.providers.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "../agents/models-config.providers.js"; import type { OpenClawConfig } from "../config/config.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; @@ -155,6 +155,9 @@ function canonicalizeOpenRouterLookupId(id: string): string { if (provider === "google") { model = normalizeGoogleModelId(model); } + if (provider === "x-ai") { + model = normalizeXaiModelId(model); + } return `${provider}/${model}`; } diff --git a/src/plugin-sdk/provider-models.ts b/src/plugin-sdk/provider-models.ts index 7103147e91d..da71fc796aa 100644 --- a/src/plugin-sdk/provider-models.ts +++ b/src/plugin-sdk/provider-models.ts @@ -24,6 +24,7 @@ export { XAI_TOOL_SCHEMA_PROFILE, } from "../agents/model-compat.js"; export { normalizeProviderId } from "../agents/provider-id.js"; +export { normalizeXaiModelId } from "../agents/model-id-normalization.js"; export { cloneFirstTemplateModel } from "../plugins/provider-model-helpers.js"; export {