fix(xai): give new Grok releases thinking levels and image input (#156397)

Recognize plain, latest-alias, and dated Grok release IDs using xAI's documented capability ranges, so newer releases receive supported thinking levels and image input without another exact-ID update. Keep Grok 4.20 and variant suffixes on their existing conservative paths, and share the release rule with Responses tool defaults.

Validation: 56 focused regression tests passed on the refreshed head; independent review found no P0/P1 issues.

Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Serg 2026-09-23 15:18:28 +02:00 • committed by GitHub
parent 63e4773938
commit 4f43e47518
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 64 additions and 32 deletions

View file

@ -187,6 +187,14 @@ metadata without joining the published inventory. Their pricing remains unknown,
recorded as zero until the manifest includes them. Zero is an unavailable estimate, not a claim that
the provider charges nothing.
Thinking levels follow xAI's
[documented release rule](https://docs.x.ai/developers/model-capabilities/text/reasoning)
rather than a fixed model list. Grok 4.5 and later accept `low`, `medium`, and
`high`; Grok 4.6 and later add `xhigh`. The default is `high`, and reasoning cannot
be turned off. A newer release id such as `grok-4.8`, its `-latest` alias, or a
dated snapshot gets these levels and image input before the manifest lists it.
Variant ids such as `-fast` keep reasoning effort off.
## Feature coverage
The bundled plugin maps supported xAI APIs onto OpenClaw's shared provider and

View file

@ -1,17 +1,33 @@
// Xai plugin module implements model id behavior.
// A Grok release id: plain, `-latest`, or a dated snapshot (grok-4.7, grok-5, grok-4.8-0115).
// Other suffixes (fast, mini, reasoning variants) name models with their own contracts.
const XAI_GROK_RELEASE_ID = /^grok-(\d+)(?:\.(\d+))?(?:-(?:latest|\d{4}))?$/u;
/**
* xAI documents reasoning effort from Grok 4.5 and xhigh "on grok-4.6 and later", so
* compare release numbers: new Grok releases work before the manifest lists them.
*/
export function isXaiGrokReleaseAtLeast(id: string, minimum: readonly [number, number]): boolean {
const match = XAI_GROK_RELEASE_ID.exec(normalizeXaiModelId(id.trim().toLowerCase()));
if (!match) {
return false;
}
const major = Number(match[1]);
const minor = Number(match[2] ?? 0);
// Grok 4.20 predates Grok 4.3 despite its number and has no reasoning effort control.
if (major === 4 && minor === 20) {
return false;
}
return major > minimum[0] || (major === minimum[0] && minor >= minimum[1]);
}
export function isXaiXhighModelId(id: string): boolean {
const normalized = normalizeXaiModelId(id.trim().toLowerCase());
return normalized === "grok-4.7" || normalized === "grok-4.6";
return isXaiGrokReleaseAtLeast(id, [4, 6]);
}
export function isXaiFrontierModelId(id: string): boolean {
const normalized = normalizeXaiModelId(id.trim().toLowerCase());
return (
normalized === "grok-4.7" ||
normalized === "grok-4.6" ||
normalized === "grok-4.5" ||
normalized.startsWith("grok-4.5-")
);
return isXaiGrokReleaseAtLeast(id, [4, 5]);
}
export function normalizeXaiModelId(id: string): string {

View file

@ -50,6 +50,10 @@ describe("xai provider thinking policy", () => {
["x-ai", "grok-4.7"],
["xai", "grok-4.6"],
["x-ai", "grok-4.6"],
// Releases newer than the manifest follow xAI's "grok-4.6 and later" rule.
["xai", "grok-4.8"],
["xai", "grok-4.8-latest"],
["xai", "grok-5"],
])("exposes xhigh reasoning for %s/%s", (provider, modelId) => {
expect(resolveThinkingProfile({ provider, modelId })).toEqual({
levels: [{ id: "low" }, { id: "medium" }, { id: "high" }, { id: "xhigh" }],
@ -95,6 +99,10 @@ describe("xai provider thinking policy", () => {
["x-ai", "grok-build-0.1"],
["x-ai", "grok-4.20-0309-reasoning"],
["x-ai", "grok-4.20-beta-latest-reasoning"],
// Grok 4.20 predates 4.3, and variant suffixes are separate model contracts.
["xai", "grok-4.20"],
["xai", "grok-4-0709"],
["xai", "grok-4.8-fast"],
])("does not advertise configurable reasoning for %s/%s", (provider, modelId) => {
expect(resolveThinkingProfile({ provider, modelId })).toEqual({
levels: [{ id: "off" }],

View file

@ -39,25 +39,25 @@ const callers = [
},
];
it.each(callers)(
"keeps the $name default request within supported reasoning efforts",
async ({ run }) => {
const request = vi.fn<typeof fetch>(async () =>
Response.json({ output_text: "fixture result" }),
);
vi.stubGlobal("fetch", withFetchPreconnect(request));
await run("grok-4.7");
expect(request).toHaveBeenCalledOnce();
const init = request.mock.calls[0]?.[1];
expect(init).toEqual(expect.objectContaining({ body: expect.any(String) }));
const body = new Request("https://api.x.ai/v1/responses", init);
expect(await body.json()).toMatchObject({
model: "grok-4.7",
store: false,
reasoning: { effort: "low" },
});
},
);
// Releases newer than the setup default keep its tool effort.
it.each(
callers.flatMap(({ name, run }) =>
["grok-4.7", "grok-4.8"].map((model) => ({ name, run, model })),
),
)("keeps the $name $model request within supported reasoning efforts", async ({ run, model }) => {
const request = vi.fn<typeof fetch>(async () => Response.json({ output_text: "fixture result" }));
vi.stubGlobal("fetch", withFetchPreconnect(request));
await run(model);
expect(request).toHaveBeenCalledOnce();
const init = request.mock.calls[0]?.[1];
expect(init).toEqual(expect.objectContaining({ body: expect.any(String) }));
const body = new Request("https://api.x.ai/v1/responses", init);
expect(await body.json()).toMatchObject({
model,
store: false,
reasoning: { effort: "low" },
});
});
it.each(callers)("preserves an explicit model and omitted effort for $name", async ({ run }) => {
const request = vi.fn<typeof fetch>(async () => Response.json({ output_text: "fixture result" }));

View file

@ -24,7 +24,7 @@ describe("xai runtime model compat", () => {
});
});
it.each(["grok-4.7", "grok-4.6"])("preserves %s xhigh reasoning", (id) => {
it.each(["grok-4.8", "grok-4.7", "grok-4.6"])("preserves %s xhigh reasoning", (id) => {
const model = applyXaiRuntimeModelCompat({
id,
provider: "xai",

View file

@ -8,6 +8,7 @@ import {
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { resolveXaiCatalogEntry } from "../model-definitions.js";
import { isXaiGrokReleaseAtLeast } from "../model-id.js";
import { applyXaiRuntimeModelCompat } from "../runtime-model-compat.js";
import type { XaiWebSearchResponse } from "./web-search-response.types.js";
@ -64,9 +65,7 @@ export function resolveXaiToolDefaultReasoningEffort(
preferred: "none" | "low",
): "none" | "low" | undefined {
// Per-model tool defaults must survive changes to the setup default.
return model === "grok-4.3" || model === "grok-4.6" || model === "grok-4.7"
? preferred
: undefined;
return model === "grok-4.3" || isXaiGrokReleaseAtLeast(model, [4, 6]) ? preferred : undefined;
}
function buildXaiResponsesToolBody(params: {

View file

@ -34,6 +34,7 @@ it.each([
{ id: "grok-3-mini-fast", reasoning: true, input: ["text"], maxTokens: 64_000 },
{ id: "grok-4.20-reasoning", reasoning: true, input: ["text", "image"], maxTokens: 30_000 },
{ id: "grok-4.20-non-reasoning", reasoning: false, input: ["text", "image"], maxTokens: 30_000 },
{ id: "grok-4.8", reasoning: true, input: ["text", "image"], maxTokens: 64_000 },
])(
"keeps supported capabilities separate from inventory and pricing for $id",
({ id, ...capabilities }) => {