diff --git a/docs/providers/xai.md b/docs/providers/xai.md index 836285a2e13..f9e184bcf54 100644 --- a/docs/providers/xai.md +++ b/docs/providers/xai.md @@ -523,7 +523,11 @@ Legacy aliases still normalize to the canonical bundled ids: `agents.defaults.models["xai/"].params.tool_stream` to `false` to disable it. - The bundled xAI wrapper strips unsupported strict tool-schema flags and - reasoning payload keys before sending native xAI requests. + reasoning *effort* payload keys before sending native xAI requests. Only + `grok-4.3` / `grok-4.3-*` advertise configurable reasoning effort; all + other reasoning-capable xAI models still request + `include: ["reasoning.encrypted_content"]` so prior encrypted reasoning + can be replayed on follow-up turns. - `web_search`, `x_search`, and `code_execution` are exposed as OpenClaw tools. OpenClaw enables the specific xAI built-in it needs inside each tool request instead of attaching all native tools to every chat turn. diff --git a/extensions/xai/index.test.ts b/extensions/xai/index.test.ts index b4a623e3f95..3158e4b3248 100644 --- a/extensions/xai/index.test.ts +++ b/extensions/xai/index.test.ts @@ -188,6 +188,33 @@ describe("xai provider plugin", () => { "grok-composer-2.5-fast", "grok-build", ]); + const composer = result.provider.models.find((model) => model.id === "grok-composer-2.5-fast"); + if (!composer) { + throw new Error("expected OAuth Composer model"); + } + expect(composer.reasoning).toBe(true); + expect(result.provider.models.find((model) => model.id === "grok-build")?.reasoning).toBe(true); + const normalizedComposer = provider.normalizeResolvedModel?.({ + provider: "xai", + modelId: composer.id, + model: { ...composer, provider: "xai" }, + } as never); + if (!normalizedComposer) { + throw new Error("expected normalized OAuth Composer model"); + } + const capture = createXaiPayloadCaptureStream(); + const wrapped = provider.wrapStreamFn?.({ + provider: "xai", + modelId: normalizedComposer.id, + extraParams: {}, + streamFn: capture.streamFn, + } as never); + if (!wrapped) { + throw new Error("expected xAI stream wrapper"); + } + void wrapped(normalizedComposer as never, { messages: [] } as never, {}); + expect(capture.getCapturedPayload()).not.toHaveProperty("reasoning"); + expect(capture.getCapturedPayload()?.include).toEqual(["reasoning.encrypted_content"]); expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({ provider: "xai", cfg: { models: {} }, diff --git a/extensions/xai/provider-catalog.ts b/extensions/xai/provider-catalog.ts index f516aded932..0a0c4e23f2f 100644 --- a/extensions/xai/provider-catalog.ts +++ b/extensions/xai/provider-catalog.ts @@ -23,6 +23,9 @@ const XAI_GROK_OAUTH_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; const XAI_GROK_OAUTH_MODELS_ENDPOINT = `${XAI_GROK_OAUTH_BASE_URL}/models`; const XAI_MODELS_CACHE_TTL_MS = 60_000; const XAI_GROK_OAUTH_MODELS_CACHE_TTL_MS = 60_000; +// Composer emits replayable Responses reasoning, but the OAuth catalog omits that capability. +// Keep it classified here or the stream wrapper will omit encrypted reasoning from replay. +const XAI_GROK_OAUTH_REASONING_MODEL_IDS = new Set(["grok-composer-2.5-fast"]); const XAI_UNKNOWN_MODEL_COST = { input: 0, output: 0, @@ -146,11 +149,13 @@ function buildXaiOauthModelFromLiveRow(row: unknown): ModelDefinitionConfig | un readLiveModelPositiveInteger(row, ["max_completion_tokens", "maxCompletionTokens"]) ?? fallback?.maxTokens ?? XAI_DEFAULT_MAX_TOKENS; - const reasoning = + const supportsReasoningEffort = readLiveModelBoolean(row, "supports_reasoning_effort") ?? - readLiveModelBoolean(row, "supportsReasoningEffort") ?? - fallback?.reasoning ?? - false; + readLiveModelBoolean(row, "supportsReasoningEffort"); + const reasoning = + supportsReasoningEffort === true || + fallback?.reasoning === true || + XAI_GROK_OAUTH_REASONING_MODEL_IDS.has(modelId); return { id: modelId, diff --git a/extensions/xai/runtime-model-compat.ts b/extensions/xai/runtime-model-compat.ts index f1d34ed0e6c..7017190fb29 100644 --- a/extensions/xai/runtime-model-compat.ts +++ b/extensions/xai/runtime-model-compat.ts @@ -1,4 +1,6 @@ // Xai plugin module implements runtime model compat behavior. +// Reasoning effort is configurable only for grok-4.3*; encrypted reasoning include/replay is +// handled separately in stream.ts for all reasoning-capable xAI models. import { applyXaiModelCompat } from "./model-compat.js"; type XaiRuntimeModelCompat = { diff --git a/extensions/xai/stream.test.ts b/extensions/xai/stream.test.ts index 742b994c5d3..3e80edcab83 100644 --- a/extensions/xai/stream.test.ts +++ b/extensions/xai/stream.test.ts @@ -395,6 +395,55 @@ describe("xai stream wrappers", () => { expect(payload).not.toHaveProperty("reasoning_effort"); }); + it("still requests encrypted reasoning include when effort is unsupported", () => { + const payload: Record = { + reasoning: { effort: "high" }, + input: [], + }; + const baseStreamFn: StreamFn = (model, _context, options) => { + options?.onPayload?.(payload, model); + return {} as ReturnType; + }; + const wrapped = createXaiToolPayloadCompatibilityWrapper(baseStreamFn); + + void wrapped( + { + api: "openai-responses", + provider: "xai", + id: "grok-build-0.1", + reasoning: true, + compat: { supportsReasoningEffort: false }, + } as unknown as Model<"openai-responses">, + { messages: [] } as Context, + {}, + ); + + expect(payload).not.toHaveProperty("reasoning"); + expect(payload.include).toEqual(["reasoning.encrypted_content"]); + }); + + it("merges encrypted reasoning include with existing include entries", () => { + const payload: Record = { + include: ["file_search_call.results"], + }; + runXaiToolPayloadWrapper({ + payload, + modelId: "grok-build-0.1", + }); + + expect(payload.include).toEqual(["file_search_call.results", "reasoning.encrypted_content"]); + }); + + it("does not request encrypted reasoning include for non-reasoning xai models", () => { + const payload: Record = {}; + runXaiToolPayloadWrapper({ + payload, + modelId: "grok-4-fast-non-reasoning", + }); + + expect(payload).not.toHaveProperty("include"); + }); + it("keeps native xAI Responses thinking efforts before the shared runtime dispatches payloads", async () => { const payload = await captureXaiResponsesPayloadWithThinking(); diff --git a/extensions/xai/stream.ts b/extensions/xai/stream.ts index de220fc9441..bafc37f1d35 100644 --- a/extensions/xai/stream.ts +++ b/extensions/xai/stream.ts @@ -53,6 +53,26 @@ function supportsReasoningControls(model: { compat?: unknown; reasoning?: unknow return model.reasoning === true && compat?.supportsReasoningEffort !== false; } +const XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE = "reasoning.encrypted_content"; + +/** xAI-only: request encrypted reasoning for every reasoning-capable model, even when effort is unsupported. */ +function ensureXaiResponsesEncryptedReasoningInclude( + payloadObj: Record, + model: { api?: unknown; provider?: unknown; reasoning?: unknown }, +): void { + if (model.provider !== "xai" || model.api !== "openai-responses" || model.reasoning !== true) { + return; + } + const existing = payloadObj.include; + const include = Array.isArray(existing) + ? existing.filter((entry): entry is string => typeof entry === "string") + : []; + if (!include.includes(XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE)) { + include.push(XAI_REASONING_ENCRYPTED_CONTENT_INCLUDE); + } + payloadObj.include = include; +} + const TOOL_RESULT_IMAGE_REPLAY_TEXT = "Attached image(s) from tool result:"; type ReplayableInputImagePart = @@ -181,10 +201,13 @@ export function createXaiToolPayloadCompatibilityWrapper( } normalizeXaiResponsesToolResultPayload(payloadObj, model); if (!supportsReasoningControls(model)) { + // Only grok-4.3* advertises configurable effort; drop effort fields elsewhere. delete payloadObj.reasoning; delete payloadObj.reasoningEffort; delete payloadObj.reasoning_effort; } + // All reasoning xAI models should still request + later replay encrypted_content. + ensureXaiResponsesEncryptedReasoningInclude(payloadObj, model); } return originalOnPayload?.(payload, model); },