From ce9c040e42e0b83cfe9e4cda3db1d9f21fa5c908 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 5 Aug 2026 17:51:57 -0500 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=A4=96=20feat:=20support=20xAI=20Grok?= =?UTF-8?q?=20ZDR=20with=20encrypted=20reasoning=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire store=false through xAI provider config/options/UI and preserve encrypted reasoning metadata so multi-turn Grok quality matches non-ZDR. --- .../Sections/ProvidersSection.stories.tsx | 3 + .../Settings/Sections/ProvidersSection.tsx | 173 ++++++++++++------ .../Settings/Sections/settingsStoryUtils.tsx | 2 + .../config/schemas/providersConfig.test.ts | 6 + src/common/config/schemas/providersConfig.ts | 2 + src/common/orpc/schemas/api.ts | 1 + src/common/schemas/providerOptions.ts | 6 + src/common/types/message.ts | 14 +- src/common/utils/ai/providerOptions.test.ts | 22 +++ src/common/utils/ai/providerOptions.ts | 21 ++- src/node/services/providerModelFactory.ts | 43 ++++- src/node/services/providerService.test.ts | 19 ++ src/node/services/providerService.ts | 4 +- src/node/services/streamManager.ts | 121 +++++++++--- .../messages/reasoningProviderOptions.test.ts | 87 +++++++++ .../messages/reasoningProviderOptions.ts | 90 +++++++++ 16 files changed, 521 insertions(+), 93 deletions(-) create mode 100644 src/node/utils/messages/reasoningProviderOptions.test.ts create mode 100644 src/node/utils/messages/reasoningProviderOptions.ts diff --git a/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx b/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx index 6f9e7c9c25..0071a92ab7 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx @@ -114,6 +114,7 @@ export const XAIProcessingMode: Story = { isEnabled: true, isConfigured: true, serviceTier: "priority", + store: false, }, }, }) @@ -127,6 +128,8 @@ export const XAIProcessingMode: Story = { const xaiButton = await canvas.findByRole("button", { name: /xAI/i }); await userEvent.click(xaiButton); await canvas.findByText("fast (priority)"); + // ZDR response storage control is available alongside processing mode. + await canvas.findByText("Response storage"); }, }; diff --git a/src/browser/features/Settings/Sections/ProvidersSection.tsx b/src/browser/features/Settings/Sections/ProvidersSection.tsx index 2544fd3f3b..8ab563db4b 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.tsx @@ -2666,68 +2666,123 @@ export function ProvidersSection() { })()} {provider === "xai" && ( -
-
- - - - - - ? - - - -
-
xAI processing mode
-
- standard: normal - scheduling and token pricing. -
-
- fast: priority - scheduling for lower latency at 2× token pricing. +
+
+
+ + + + + + ? + + + +
+
xAI processing mode
+
+ standard: normal + scheduling and token pricing. +
+
+ fast: priority + scheduling for lower latency at 2× token pricing. +
-
- - - + + + +
+
- { + if (!api) return; + if (next !== "enabled" && next !== "disabled") return; + + const store = next === "disabled" ? false : undefined; + updateOptimistically("xai", { store }); + void api.providers.setProviderConfig({ provider: "xai", - keyPath: ["serviceTier"], - value: next, - }) - .then( - (result) => { - if (result.success) { - updateOptimistically("xai", { serviceTier: next }); - return undefined; - } - return refresh(); - }, - () => refresh() - ) - .finally(() => setXAIServiceTierSaving(false)); - }} - > - - - - - standard - fast (priority) - - + keyPath: ["store"], + value: next === "disabled" ? false : "", + }); + }} + > + + + + + enabled + disabled + + +
)} diff --git a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx index c53c6415f2..89f37769bf 100644 --- a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx +++ b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx @@ -135,6 +135,8 @@ interface SetupSettingsStoryOptions { baseUrlSource?: "config" | "env"; baseUrlResolved?: string; serviceTier?: ServiceTier; + /** OpenAI/xAI Responses storage (false for ZDR). */ + store?: boolean; models?: string[]; } >; diff --git a/src/common/config/schemas/providersConfig.test.ts b/src/common/config/schemas/providersConfig.test.ts index fca4872a21..b3e7b9ef77 100644 --- a/src/common/config/schemas/providersConfig.test.ts +++ b/src/common/config/schemas/providersConfig.test.ts @@ -99,6 +99,12 @@ describe("ProvidersConfigSchema", () => { ).toBe(false); }); + it("accepts xAI store flag for ZDR", () => { + expect(ProvidersConfigSchema.safeParse({ xai: { store: false } }).success).toBe(true); + expect(ProvidersConfigSchema.safeParse({ xai: { store: true } }).success).toBe(true); + expect(ProvidersConfigSchema.safeParse({ xai: { store: "false" } }).success).toBe(false); + }); + describe("modelParameters", () => { it("accepts valid per-model and wildcard overrides", () => { const valid = { diff --git a/src/common/config/schemas/providersConfig.ts b/src/common/config/schemas/providersConfig.ts index 9621bdd6c6..617aae7965 100644 --- a/src/common/config/schemas/providersConfig.ts +++ b/src/common/config/schemas/providersConfig.ts @@ -67,6 +67,8 @@ export const XAIProviderConfigSchema = BaseProviderConfigSchema.extend({ searchParameters: z.record(z.string(), z.unknown()).optional(), serviceTier: XAIServiceTierSchema.optional(), fastModePreviousServiceTier: XAIFastModePreviousServiceTierSchema.optional(), + // Required for xAI ZDR orgs on Grok 4.5 Responses (same semantics as OpenAI store). + store: z.boolean().optional(), }); export const MuxGatewayProviderConfigSchema = BaseProviderConfigSchema.extend({ diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 38f440f54d..fd85f7957c 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -251,6 +251,7 @@ export const ProviderConfigInfoSchema = z.object({ serviceTier: ServiceTierSchema.optional(), fastModePreviousServiceTier: FastModePreviousServiceTierSchema.optional(), wireFormat: z.enum(["responses", "chatCompletions"]).optional(), + /** OpenAI/xAI Responses storage. Set false for ZDR orgs. */ store: z.boolean().optional(), webSocketTransportEnabled: z.boolean().optional(), /** Anthropic-specific fields */ diff --git a/src/common/schemas/providerOptions.ts b/src/common/schemas/providerOptions.ts index 7eb2d65aed..71da9aacdf 100644 --- a/src/common/schemas/providerOptions.ts +++ b/src/common/schemas/providerOptions.ts @@ -64,6 +64,12 @@ export const MuxProviderOptionsSchema = z.object({ description: 'xAI processing tier: "priority" requests faster processing at 2× token pricing; "default" uses standard processing', }), + // Grok 4.5 Responses defaults to store=true; ZDR orgs must set false or + // requests fail. @ai-sdk/xai then auto-includes reasoning.encrypted_content + // so multi-turn tool use can keep reasoning quality without server storage. + store: z.boolean().optional().meta({ + description: "Whether xAI stores responses. Set false for zero data retention (ZDR).", + }), searchParameters: z .object({ mode: z.enum(["auto", "off", "on"]), diff --git a/src/common/types/message.ts b/src/common/types/message.ts index 3606bb2ad9..b01f034893 100644 --- a/src/common/types/message.ts +++ b/src/common/types/message.ts @@ -657,13 +657,23 @@ export interface MuxReasoningPart { /** * Provider options for SDK compatibility. * When converting to ModelMessages via the SDK's convertToModelMessages, - * this is passed through. For Anthropic thinking blocks, this should contain - * { anthropic: { signature } } to allow reasoning replay. + * this is passed through so reasoning can be replayed: + * - Anthropic: { anthropic: { signature } } + * - OpenAI/xAI Responses (esp. store=false/ZDR): itemId + reasoningEncryptedContent + * so the next turn can restore encrypted reasoning without server-side storage. */ providerOptions?: { anthropic?: { signature?: string; }; + openai?: { + itemId?: string; + reasoningEncryptedContent?: string | null; + }; + xai?: { + itemId?: string; + reasoningEncryptedContent?: string | null; + }; }; } diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index c67bf786eb..e1423c593c 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -1663,6 +1663,28 @@ describe("buildProviderOptions - xAI", () => { }, }); }); + + test("includes store: false for xAI ZDR without dropping reasoning effort", () => { + expect( + buildProviderOptions("xai:grok-4.5", "medium", undefined, undefined, { + xai: { store: false }, + }) + ).toEqual({ + xai: { + reasoningEffort: "medium", + store: false, + }, + }); + }); + + test("omits store key when xAI store is unset", () => { + const result = buildProviderOptions("xai:grok-4.5", "medium", undefined, undefined, { + xai: {}, + }); + const xai = (result as { xai?: Record }).xai; + expect(xai).toBeDefined(); + expect("store" in xai!).toBe(false); + }); }); describe("buildRequestHeaders", () => { diff --git a/src/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index 9cf7a7cae0..07cd28d733 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -10,7 +10,11 @@ import type { AnthropicProviderOptions } from "@ai-sdk/anthropic"; import type { GoogleGenerativeAIProviderOptions } from "@ai-sdk/google"; import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; import type { JSONValue } from "@ai-sdk/provider"; -import type { XaiProviderOptions } from "@ai-sdk/xai"; +import type { + XaiProviderOptions, + // Chat options alias does not include store; Responses options do (Grok 4.5 / ZDR). + XaiResponsesProviderOptions, +} from "@ai-sdk/xai"; import type { ProviderName } from "@/common/constants/providers"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; @@ -73,6 +77,12 @@ interface MoonshotAIProviderOptions { reasoningEffort?: "max"; } +/** + * xAI providerOptions payload. Chat models use XaiProviderOptions; Grok 4.5 + * Responses also accepts store (ZDR). Union keeps both families assignable. + */ +type XaiBuiltProviderOptions = XaiProviderOptions & Pick; + /** * Provider-specific options structure for AI SDK */ @@ -82,7 +92,7 @@ type ProviderOptions = | { google: GoogleGenerativeAIProviderOptions } | { openrouter: OpenRouterReasoningOptions } | { moonshotai: MoonshotAIProviderOptions } - | { xai: XaiProviderOptions } + | { xai: XaiBuiltProviderOptions } | { "github-copilot": OpenAICompatibleGatewayProviderOptions } | Record; // Empty object for unsupported providers @@ -545,6 +555,7 @@ export function buildProviderOptions( const { serviceTier: _serviceTier, searchParameters, + store, ...overrides } = muxProviderOptions?.xai ?? {}; const isGrok45 = isGrok45Model(capabilityModel); @@ -565,13 +576,17 @@ export function buildProviderOptions( xai: { ...overrides, ...(reasoningEffort != null && { reasoningEffort }), + // ZDR: store:false is required for ZDR orgs. @ai-sdk/xai also auto-adds + // include=["reasoning.encrypted_content"] so reasoning can round-trip + // without server-side response storage (parity with non-ZDR quality). + ...(store != null && { store }), // Grok 4.5 uses xAI's modern Responses tools; getToolsForModel translates // legacy Live Search settings instead of sending deprecated search_parameters. ...(!isGrok45 && { searchParameters: searchParameters ?? defaultSearchParameters, }), }, - } satisfies { xai: XaiProviderOptions }; + } satisfies { xai: XaiBuiltProviderOptions }; log.debug("buildProviderOptions: Returning xAI options", options); return options; } diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index e04d2cbaf3..347471a95c 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -1086,6 +1086,17 @@ export class ProviderModelFactory { }; } + // xAI-specific: merge global store setting for Grok Responses ZDR orgs. + // Grok 4.5 defaults to store=true; ZDR teams need store=false or the API errors. + const configXAIStore = providersConfig.xai?.store; + if (providerName === "xai" && typeof configXAIStore === "boolean") { + muxProviderOptions ??= {}; + muxProviderOptions.xai = { + ...(muxProviderOptions.xai ?? {}), + store: muxProviderOptions.xai?.store ?? configXAIStore, + }; + } + let providerConfig = providersConfig[providerName] ?? {}; // Providers can be disabled in providers.jsonc without deleting credentials. @@ -1518,9 +1529,35 @@ export class ProviderModelFactory { // that capability; older custom model strings stay on Chat Completions for // legacy search_parameters compatibility. const capabilityModel = resolveModelForMetadata(`xai:${modelId}`, providersConfig); - return Ok( - isGrok45Model(capabilityModel) ? provider.responses(modelId) : provider.chat(modelId) - ); + const model = isGrok45Model(capabilityModel) + ? provider.responses(modelId) + : provider.chat(modelId); + + // Inject configured xAI store as a request-level default so callers that + // omit providerOptions still honor global ZDR settings (mirrors OpenAI). + const configuredXAIStore = muxProviderOptions?.xai?.store; + if (typeof configuredXAIStore === "boolean") { + const injectStoreFlag = ( + options: Parameters[0] + ): Parameters[0] => { + const xaiOpts = + (options.providerOptions?.xai as Record | undefined) ?? {}; + return { + ...options, + providerOptions: { + ...options.providerOptions, + xai: { store: configuredXAIStore, ...xaiOpts }, + }, + }; + }; + + const originalDoStream = model.doStream.bind(model); + const originalDoGenerate = model.doGenerate.bind(model); + model.doStream = (options) => originalDoStream(injectStoreFlag(options)); + model.doGenerate = (options) => originalDoGenerate(injectStoreFlag(options)); + } + + return Ok(model); } // Handle Ollama provider diff --git a/src/node/services/providerService.test.ts b/src/node/services/providerService.test.ts index 6eb5a78df5..a45605048f 100644 --- a/src/node/services/providerService.test.ts +++ b/src/node/services/providerService.test.ts @@ -257,6 +257,25 @@ describe("ProviderService.getConfig", () => { }); }); + it("surfaces store: false for xAI ZDR", () => { + withTempConfig((config, service) => { + config.saveProvidersConfig({ + xai: { + apiKey: "xai-key", + store: false, + }, + }); + expect(service.getConfig().xai.store).toBe(false); + + config.saveProvidersConfig({ + xai: { + apiKey: "xai-key", + }, + }); + expect(service.getConfig().xai.store).toBeUndefined(); + }); + }); + it("surfaces non-secret op:// API key references", () => { withTempConfig((config, service) => { const opRef = "op://Personal/Anthropic/credential"; diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index c5e52b1fdf..74ea13e245 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -399,8 +399,8 @@ export class ProviderService { providerInfo.wireFormat = wireFormat; } - // OpenAI-specific: response storage setting (required for ZDR) - if (provider === "openai" && typeof config.store === "boolean") { + // OpenAI/xAI: response storage setting (required for ZDR on Responses API) + if ((provider === "openai" || provider === "xai") && typeof config.store === "boolean") { providerInfo.store = config.store; } diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index a5d1b2ac91..abee6b41b8 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -32,6 +32,12 @@ import type { import type { SendMessageError, StreamErrorType } from "@/common/types/errors"; import type { MuxMetadata, MuxMessage, PersistedToolModelUsage } from "@/common/types/message"; +import { + findFirstReasoningPartIndexInTrailingRun, + mergeReasoningProviderOptions, + reasoningProviderOptionsFromMetadata, + type ReasoningProviderMetadata, +} from "@/node/utils/messages/reasoningProviderOptions"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { ActiveTurnThinkingOverride, @@ -141,12 +147,13 @@ interface ReasoningDeltaPart { type: "reasoning-delta"; text?: string; delta?: string; - providerMetadata?: { - anthropic?: { - signature?: string; - redactedData?: string; - }; - }; + providerMetadata?: ReasoningProviderMetadata; +} + +interface ReasoningLifecyclePart { + type: "reasoning-start" | "reasoning-end"; + id?: string; + providerMetadata?: ReasoningProviderMetadata; } // Tool-call tracking + branded types @@ -2883,29 +2890,71 @@ export class StreamManager extends EventEmitter { break; } + case "reasoning-start": { + // OpenAI/xAI may attach itemId (and sometimes encrypted content) on start. + // Stash on the latest reasoning part, or open an empty one for later deltas. + const lifecyclePart = part as ReasoningLifecyclePart; + const startOptions = reasoningProviderOptionsFromMetadata( + lifecyclePart.providerMetadata + ); + if (startOptions) { + const lastPart = streamInfo.parts.at(-1); + if (lastPart?.type === "reasoning") { + lastPart.providerOptions = mergeReasoningProviderOptions( + lastPart.providerOptions, + startOptions + ); + void this.schedulePartialWrite(workspaceId, streamInfo); + } else { + await this.appendPartAndEmit( + workspaceId, + streamInfo, + { + type: "reasoning" as const, + text: "", + timestamp: nextPartTimestamp(streamInfo), + providerOptions: startOptions, + }, + true + ); + } + } + break; + } + case "reasoning-delta": { - // Both Anthropic and OpenAI use reasoning-delta for streaming reasoning content + // Anthropic, OpenAI, and xAI stream reasoning content via reasoning-delta. const reasoningPart = part as ReasoningDeltaPart; const delta = reasoningPart.text ?? reasoningPart.delta ?? ""; const signature = reasoningPart.providerMetadata?.anthropic?.signature; + const deltaOptions = reasoningProviderOptionsFromMetadata( + reasoningPart.providerMetadata + ); - // Signature deltas come separately with empty text - attach to last reasoning part - if (signature && !delta) { + // Metadata-only deltas (Anthropic signature, OpenAI/xAI itemId) attach to + // the latest reasoning part without creating a new empty text part. + if (!delta && (signature || deltaOptions)) { const lastPart = streamInfo.parts.at(-1); if (lastPart?.type === "reasoning") { - lastPart.signature = signature; - // Also set providerOptions for SDK compatibility when converting to ModelMessages - lastPart.providerOptions = { anthropic: { signature } }; - // Emit signature update event - this.emit("reasoning-delta", { - type: "reasoning-delta", - workspaceId: workspaceId as string, - messageId: streamInfo.messageId, - delta: "", - tokens: 0, - timestamp: nextPartTimestamp(streamInfo), - signature, - }); + if (signature) { + lastPart.signature = signature; + } + lastPart.providerOptions = mergeReasoningProviderOptions( + lastPart.providerOptions, + deltaOptions ?? (signature ? { anthropic: { signature } } : undefined) + ); + // Emit signature update event for Anthropic UI consumers. + if (signature) { + this.emit("reasoning-delta", { + type: "reasoning-delta", + workspaceId: workspaceId as string, + messageId: streamInfo.messageId, + delta: "", + tokens: 0, + timestamp: nextPartTimestamp(streamInfo), + signature, + }); + } void this.schedulePartialWrite(workspaceId, streamInfo); } break; @@ -2918,14 +2967,38 @@ export class StreamManager extends EventEmitter { text: delta, timestamp: nextPartTimestamp(streamInfo), signature, // May be undefined, will be filled by subsequent signature delta - providerOptions: signature ? { anthropic: { signature } } : undefined, + providerOptions: deltaOptions, }; await this.appendPartAndEmit(workspaceId, streamInfo, newPart, true); break; } case "reasoning-end": { - // Reasoning-end is just a signal - no state to update + // xAI (and OpenAI store=false) put reasoningEncryptedContent on reasoning-end. + // Mux streams reasoning as many tiny delta parts; providers expect one + // reasoning item with encrypted content. Attach metadata to the first part + // of the contiguous reasoning run so convertToModelMessages can replay it + // even if later parts lack providerOptions. + const lifecyclePart = part as ReasoningLifecyclePart; + const endOptions = reasoningProviderOptionsFromMetadata( + lifecyclePart.providerMetadata + ); + if (endOptions) { + const firstReasoningIndex = findFirstReasoningPartIndexInTrailingRun( + streamInfo.parts + ); + if (firstReasoningIndex >= 0) { + const firstPart = streamInfo.parts[firstReasoningIndex]; + if (firstPart?.type === "reasoning") { + firstPart.providerOptions = mergeReasoningProviderOptions( + firstPart.providerOptions, + endOptions + ); + void this.schedulePartialWrite(workspaceId, streamInfo); + } + } + } + this.emit("reasoning-end", { type: "reasoning-end", workspaceId: workspaceId as string, diff --git a/src/node/utils/messages/reasoningProviderOptions.test.ts b/src/node/utils/messages/reasoningProviderOptions.test.ts new file mode 100644 index 0000000000..ea29fef892 --- /dev/null +++ b/src/node/utils/messages/reasoningProviderOptions.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; + +import { + findFirstReasoningPartIndexInTrailingRun, + mergeReasoningProviderOptions, + reasoningProviderOptionsFromMetadata, +} from "./reasoningProviderOptions"; + +describe("reasoningProviderOptionsFromMetadata", () => { + test("maps Anthropic signatures", () => { + expect( + reasoningProviderOptionsFromMetadata({ anthropic: { signature: "sig_abc" } }) + ).toEqual({ anthropic: { signature: "sig_abc" } }); + }); + + test("maps xAI encrypted reasoning used for ZDR multi-turn quality", () => { + expect( + reasoningProviderOptionsFromMetadata({ + xai: { + itemId: "rs_1", + reasoningEncryptedContent: "enc_blob", + }, + }) + ).toEqual({ + xai: { + itemId: "rs_1", + reasoningEncryptedContent: "enc_blob", + }, + }); + }); + + test("maps OpenAI encrypted reasoning the same way", () => { + expect( + reasoningProviderOptionsFromMetadata({ + openai: { + itemId: "rs_oai", + reasoningEncryptedContent: "enc_oai", + }, + }) + ).toEqual({ + openai: { + itemId: "rs_oai", + reasoningEncryptedContent: "enc_oai", + }, + }); + }); + + test("returns undefined when metadata is empty", () => { + expect(reasoningProviderOptionsFromMetadata(undefined)).toBeUndefined(); + expect(reasoningProviderOptionsFromMetadata({})).toBeUndefined(); + expect(reasoningProviderOptionsFromMetadata({ xai: {} })).toBeUndefined(); + }); +}); + +describe("mergeReasoningProviderOptions", () => { + test("merges itemId from start with encrypted content from end", () => { + expect( + mergeReasoningProviderOptions( + { xai: { itemId: "rs_1" } }, + { xai: { reasoningEncryptedContent: "enc_blob" } } + ) + ).toEqual({ + xai: { + itemId: "rs_1", + reasoningEncryptedContent: "enc_blob", + }, + }); + }); +}); + +describe("findFirstReasoningPartIndexInTrailingRun", () => { + test("returns the first reasoning part in a trailing run of deltas", () => { + const parts = [ + { type: "text" }, + { type: "reasoning" }, + { type: "reasoning" }, + { type: "reasoning" }, + ]; + expect(findFirstReasoningPartIndexInTrailingRun(parts)).toBe(1); + }); + + test("returns -1 when the trailing part is not reasoning", () => { + expect( + findFirstReasoningPartIndexInTrailingRun([{ type: "reasoning" }, { type: "text" }]) + ).toBe(-1); + }); +}); diff --git a/src/node/utils/messages/reasoningProviderOptions.ts b/src/node/utils/messages/reasoningProviderOptions.ts new file mode 100644 index 0000000000..55674bf209 --- /dev/null +++ b/src/node/utils/messages/reasoningProviderOptions.ts @@ -0,0 +1,90 @@ +import type { MuxReasoningPart } from "@/common/types/message"; + +export interface ReasoningProviderMetadata { + anthropic?: { + signature?: string; + redactedData?: string; + }; + // OpenAI/xAI Responses attach itemId (+ encrypted content under store=false/ZDR) + // so subsequent turns can restore reasoning without server-side response storage. + openai?: { + itemId?: string; + reasoningEncryptedContent?: string | null; + }; + xai?: { + itemId?: string; + reasoningEncryptedContent?: string | null; + }; +} + +/** + * Build providerOptions for reasoning parts that convertToModelMessages must + * pass back to the provider. Anthropic needs signatures; OpenAI/xAI Responses + * need itemId + encrypted content when store=false (ZDR). + */ +export function reasoningProviderOptionsFromMetadata( + providerMetadata: ReasoningProviderMetadata | undefined +): MuxReasoningPart["providerOptions"] | undefined { + if (!providerMetadata) return undefined; + + const options: NonNullable = {}; + + const anthropicSignature = providerMetadata.anthropic?.signature; + if (typeof anthropicSignature === "string" && anthropicSignature.length > 0) { + options.anthropic = { signature: anthropicSignature }; + } + + for (const provider of ["openai", "xai"] as const) { + const meta = providerMetadata[provider]; + if (!meta) continue; + const itemId = typeof meta.itemId === "string" ? meta.itemId : undefined; + const encrypted = + typeof meta.reasoningEncryptedContent === "string" + ? meta.reasoningEncryptedContent + : meta.reasoningEncryptedContent === null + ? null + : undefined; + if (itemId == null && encrypted === undefined) continue; + options[provider] = { + ...(itemId != null ? { itemId } : {}), + ...(encrypted !== undefined ? { reasoningEncryptedContent: encrypted } : {}), + }; + } + + return Object.keys(options).length > 0 ? options : undefined; +} + +export function mergeReasoningProviderOptions( + existing: MuxReasoningPart["providerOptions"] | undefined, + incoming: MuxReasoningPart["providerOptions"] | undefined +): MuxReasoningPart["providerOptions"] | undefined { + if (!existing) return incoming; + if (!incoming) return existing; + + const merged: NonNullable = { ...existing }; + + if (incoming.anthropic) { + merged.anthropic = { ...existing.anthropic, ...incoming.anthropic }; + } + for (const provider of ["openai", "xai"] as const) { + if (!incoming[provider]) continue; + merged[provider] = { ...existing[provider], ...incoming[provider] }; + } + + return merged; +} + +/** + * Find the start index of the trailing contiguous reasoning-part run. + * Used so encrypted content on reasoning-end can attach to the first delta part. + */ +export function findFirstReasoningPartIndexInTrailingRun( + parts: ReadonlyArray<{ type?: string } | undefined> +): number { + let firstReasoningIndex = -1; + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i]?.type !== "reasoning") break; + firstReasoningIndex = i; + } + return firstReasoningIndex; +} From 0d92758334935ca9d3d1c52d988b0596753d276d Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 5 Aug 2026 17:55:34 -0500 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=A4=96=20style:=20format=20reasoning?= =?UTF-8?q?=20provider=20options=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/utils/messages/reasoningProviderOptions.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/node/utils/messages/reasoningProviderOptions.test.ts b/src/node/utils/messages/reasoningProviderOptions.test.ts index ea29fef892..56af808303 100644 --- a/src/node/utils/messages/reasoningProviderOptions.test.ts +++ b/src/node/utils/messages/reasoningProviderOptions.test.ts @@ -8,9 +8,9 @@ import { describe("reasoningProviderOptionsFromMetadata", () => { test("maps Anthropic signatures", () => { - expect( - reasoningProviderOptionsFromMetadata({ anthropic: { signature: "sig_abc" } }) - ).toEqual({ anthropic: { signature: "sig_abc" } }); + expect(reasoningProviderOptionsFromMetadata({ anthropic: { signature: "sig_abc" } })).toEqual({ + anthropic: { signature: "sig_abc" }, + }); }); test("maps xAI encrypted reasoning used for ZDR multi-turn quality", () => { From 14c0d355e49656aedaf3b652d152f2b20b3f6bb6 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 5 Aug 2026 17:59:01 -0500 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20persist=20ZDR=20store?= =?UTF-8?q?=20toggles=20before=20UI=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Settings/Sections/ProvidersSection.tsx | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/src/browser/features/Settings/Sections/ProvidersSection.tsx b/src/browser/features/Settings/Sections/ProvidersSection.tsx index 8ab563db4b..b681e5dffe 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.tsx @@ -400,6 +400,10 @@ export function ProvidersSection() { const [openaiServiceTierSelectOverride, setOpenaiServiceTierSelectOverride] = useState(null); const [xaiServiceTierSaving, setXAIServiceTierSaving] = useState(false); + // Persist ZDR store toggles before publishing UI state so a failed write cannot + // leave the dropdown claiming disabled while requests still send store=true. + const [openaiStoreSaving, setOpenAIStoreSaving] = useState(false); + const [xaiStoreSaving, setXAIStoreSaving] = useState(false); const routing = useRouting(); @@ -2639,17 +2643,30 @@ export function ProvidersSection() {
{ - if (!api) return; + if (!api || xaiStoreSaving) return; if (next !== "enabled" && next !== "disabled") return; const store = next === "disabled" ? false : undefined; - updateOptimistically("xai", { store }); - void api.providers.setProviderConfig({ - provider: "xai", - keyPath: ["store"], - value: next === "disabled" ? false : "", - }); + // Persist before publishing so ZDR cannot appear enabled + // while the backend still defaults to store=true. + setXAIStoreSaving(true); + void api.providers + .setProviderConfig({ + provider: "xai", + keyPath: ["store"], + value: next === "disabled" ? false : "", + }) + .then( + (result) => { + if (result.success) { + updateOptimistically("xai", { store }); + return undefined; + } + return refresh(); + }, + () => refresh() + ) + .finally(() => setXAIStoreSaving(false)); }} > From e1c865444dc4457c1757727a78f79c87d0ae87fc Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 5 Aug 2026 18:25:16 -0500 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20default=20Grok?= =?UTF-8?q?=20store=3Dfalse=20without=20settings=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZDR and non-ZDR share one Grok Responses path (store=false + encrypted reasoning). Drop the xAI store toggle and add multi-turn real-API coverage. --- .../Sections/ProvidersSection.stories.tsx | 3 - .../Settings/Sections/ProvidersSection.tsx | 194 ++++++------------ .../Settings/Sections/settingsStoryUtils.tsx | 2 - .../config/schemas/providersConfig.test.ts | 6 - src/common/config/schemas/providersConfig.ts | 2 - src/common/schemas/providerOptions.ts | 8 +- src/common/utils/ai/providerOptions.test.ts | 29 ++- src/common/utils/ai/providerOptions.ts | 12 +- .../services/providerModelFactory.test.ts | 80 ++++++++ src/node/services/providerModelFactory.ts | 25 +-- src/node/services/providerService.test.ts | 19 -- src/node/services/providerService.ts | 5 +- tests/ipc/providers/xaiGrok45.test.ts | 135 ++++++++++-- 13 files changed, 308 insertions(+), 212 deletions(-) diff --git a/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx b/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx index 0071a92ab7..6f9e7c9c25 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.stories.tsx @@ -114,7 +114,6 @@ export const XAIProcessingMode: Story = { isEnabled: true, isConfigured: true, serviceTier: "priority", - store: false, }, }, }) @@ -128,8 +127,6 @@ export const XAIProcessingMode: Story = { const xaiButton = await canvas.findByRole("button", { name: /xAI/i }); await userEvent.click(xaiButton); await canvas.findByText("fast (priority)"); - // ZDR response storage control is available alongside processing mode. - await canvas.findByText("Response storage"); }, }; diff --git a/src/browser/features/Settings/Sections/ProvidersSection.tsx b/src/browser/features/Settings/Sections/ProvidersSection.tsx index b681e5dffe..f16d3cd8fe 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.tsx @@ -400,10 +400,10 @@ export function ProvidersSection() { const [openaiServiceTierSelectOverride, setOpenaiServiceTierSelectOverride] = useState(null); const [xaiServiceTierSaving, setXAIServiceTierSaving] = useState(false); - // Persist ZDR store toggles before publishing UI state so a failed write cannot - // leave the dropdown claiming disabled while requests still send store=true. + // Persist OpenAI ZDR store toggles before publishing UI state so a failed write + // cannot leave the dropdown claiming disabled while requests still send store=true. + // xAI Grok 4.5 always uses store=false in the request path (no settings surface). const [openaiStoreSaving, setOpenAIStoreSaving] = useState(false); - const [xaiStoreSaving, setXAIStoreSaving] = useState(false); const routing = useRouting(); @@ -2683,138 +2683,68 @@ export function ProvidersSection() { })()} {provider === "xai" && ( -
-
-
- - - - - - ? - - - -
-
xAI processing mode
-
- standard: normal - scheduling and token pricing. -
-
- fast: priority - scheduling for lower latency at 2× token pricing. -
+
+
+ + + + + + ? + + + +
+
xAI processing mode
+
+ standard: normal + scheduling and token pricing.
- - - -
- -
- -
-
- - - - - - ? - - - -
-
xAI response storage
-
- enabled: xAI stores - responses for retrieval and context (default). -
-
- disabled: responses - are not stored. Required for zero data retention (ZDR) orgs. - Encrypted reasoning is still preserved client-side so - multi-turn quality matches non-ZDR. -
+
+ fast: priority + scheduling for lower latency at 2× token pricing.
- - - -
- +
+ + +
+
)} diff --git a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx index 89f37769bf..c53c6415f2 100644 --- a/src/browser/features/Settings/Sections/settingsStoryUtils.tsx +++ b/src/browser/features/Settings/Sections/settingsStoryUtils.tsx @@ -135,8 +135,6 @@ interface SetupSettingsStoryOptions { baseUrlSource?: "config" | "env"; baseUrlResolved?: string; serviceTier?: ServiceTier; - /** OpenAI/xAI Responses storage (false for ZDR). */ - store?: boolean; models?: string[]; } >; diff --git a/src/common/config/schemas/providersConfig.test.ts b/src/common/config/schemas/providersConfig.test.ts index b3e7b9ef77..fca4872a21 100644 --- a/src/common/config/schemas/providersConfig.test.ts +++ b/src/common/config/schemas/providersConfig.test.ts @@ -99,12 +99,6 @@ describe("ProvidersConfigSchema", () => { ).toBe(false); }); - it("accepts xAI store flag for ZDR", () => { - expect(ProvidersConfigSchema.safeParse({ xai: { store: false } }).success).toBe(true); - expect(ProvidersConfigSchema.safeParse({ xai: { store: true } }).success).toBe(true); - expect(ProvidersConfigSchema.safeParse({ xai: { store: "false" } }).success).toBe(false); - }); - describe("modelParameters", () => { it("accepts valid per-model and wildcard overrides", () => { const valid = { diff --git a/src/common/config/schemas/providersConfig.ts b/src/common/config/schemas/providersConfig.ts index 617aae7965..9621bdd6c6 100644 --- a/src/common/config/schemas/providersConfig.ts +++ b/src/common/config/schemas/providersConfig.ts @@ -67,8 +67,6 @@ export const XAIProviderConfigSchema = BaseProviderConfigSchema.extend({ searchParameters: z.record(z.string(), z.unknown()).optional(), serviceTier: XAIServiceTierSchema.optional(), fastModePreviousServiceTier: XAIFastModePreviousServiceTierSchema.optional(), - // Required for xAI ZDR orgs on Grok 4.5 Responses (same semantics as OpenAI store). - store: z.boolean().optional(), }); export const MuxGatewayProviderConfigSchema = BaseProviderConfigSchema.extend({ diff --git a/src/common/schemas/providerOptions.ts b/src/common/schemas/providerOptions.ts index 71da9aacdf..755ad8b03c 100644 --- a/src/common/schemas/providerOptions.ts +++ b/src/common/schemas/providerOptions.ts @@ -64,11 +64,11 @@ export const MuxProviderOptionsSchema = z.object({ description: 'xAI processing tier: "priority" requests faster processing at 2× token pricing; "default" uses standard processing', }), - // Grok 4.5 Responses defaults to store=true; ZDR orgs must set false or - // requests fail. @ai-sdk/xai then auto-includes reasoning.encrypted_content - // so multi-turn tool use can keep reasoning quality without server storage. + // Request-level escape hatch only. Grok 4.5 defaults to store=false in + // buildProviderOptions so ZDR and non-ZDR share one path (no settings UI). store: z.boolean().optional().meta({ - description: "Whether xAI stores responses. Set false for zero data retention (ZDR).", + description: + "Whether xAI stores Responses. Grok 4.5 defaults to false (ZDR-safe); set true only to opt back into server storage.", }), searchParameters: z .object({ diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index e1423c593c..b48f018b9f 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -1641,11 +1641,12 @@ describe("buildProviderOptions - OpenRouter", () => { describe("buildProviderOptions - xAI", () => { test("maps Grok 4.5 thinking levels to reasoning effort without deprecated search defaults", () => { + // store:false is the default so ZDR and non-ZDR orgs share one request path. expect(buildProviderOptions("xai:grok-4.5", "medium")).toEqual({ - xai: { reasoningEffort: "medium" }, + xai: { reasoningEffort: "medium", store: false }, }); expect(buildProviderOptions("xai:grok-4.5", "max")).toEqual({ - xai: { reasoningEffort: "high" }, + xai: { reasoningEffort: "high", store: false }, }); }); @@ -1660,15 +1661,14 @@ describe("buildProviderOptions - xAI", () => { ).toEqual({ xai: { reasoningEffort: "high", + store: false, }, }); }); - test("includes store: false for xAI ZDR without dropping reasoning effort", () => { + test("defaults Grok 4.5 store to false without an explicit override", () => { expect( - buildProviderOptions("xai:grok-4.5", "medium", undefined, undefined, { - xai: { store: false }, - }) + buildProviderOptions("xai:grok-4.5", "medium", undefined, undefined, { xai: {} }) ).toEqual({ xai: { reasoningEffort: "medium", @@ -1677,10 +1677,21 @@ describe("buildProviderOptions - xAI", () => { }); }); - test("omits store key when xAI store is unset", () => { - const result = buildProviderOptions("xai:grok-4.5", "medium", undefined, undefined, { - xai: {}, + test("allows explicit store: true escape hatch on Grok 4.5", () => { + expect( + buildProviderOptions("xai:grok-4.5", "medium", undefined, undefined, { + xai: { store: true }, + }) + ).toEqual({ + xai: { + reasoningEffort: "medium", + store: true, + }, }); + }); + + test("does not force store on legacy non-Grok-4.5 xAI chat models", () => { + const result = buildProviderOptions("xai:grok-4-1-fast", "off"); const xai = (result as { xai?: Record }).xai; expect(xai).toBeDefined(); expect("store" in xai!).toBe(false); diff --git a/src/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index 07cd28d733..bb894b211d 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -572,14 +572,18 @@ export function buildProviderOptions( returnCitations: true, }; + // Grok 4.5 Responses: always prefer store=false. + // Mux already resends full history explicitly and persists encrypted reasoning + // client-side, so server storage is unnecessary. Forcing store=false means ZDR + // and non-ZDR orgs share one code path and one quality bar (no settings surface). + // Explicit muxProviderOptions.xai.store still wins for tests/escapes. + const effectiveStore = isGrok45 ? (store ?? false) : store; + const options = { xai: { ...overrides, ...(reasoningEffort != null && { reasoningEffort }), - // ZDR: store:false is required for ZDR orgs. @ai-sdk/xai also auto-adds - // include=["reasoning.encrypted_content"] so reasoning can round-trip - // without server-side response storage (parity with non-ZDR quality). - ...(store != null && { store }), + ...(effectiveStore != null && { store: effectiveStore }), // Grok 4.5 uses xAI's modern Responses tools; getToolsForModel translates // legacy Live Search settings instead of sending deprecated search_parameters. ...(!isGrok45 && { diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index daf284f32d..78ae8be675 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -624,6 +624,86 @@ describe("ProviderModelFactory xAI API selection", () => { expect((result.data as { provider?: unknown }).provider).toBe("xai.chat"); }); }); + + it("defaults Grok 4.5 Responses requests to store=false for ZDR parity", async () => { + await withTempConfig(async (config, factory) => { + const originalXaiRegistry = PROVIDER_REGISTRY.xai; + config.saveProvidersConfig({ xai: { apiKey: "xai-test-key" } }); + + let capturedBody: Record | undefined; + + PROVIDER_REGISTRY.xai = async () => { + const module = await originalXaiRegistry(); + return { + ...module, + createXai: (options) => { + const mockFetch = Object.assign((_input: RequestInfo | URL, init?: RequestInit) => { + if (typeof init?.body === "string") { + capturedBody = JSON.parse(init.body) as Record; + } + return Promise.resolve( + new Response( + JSON.stringify({ + id: "resp_test", + created_at: 1, + model: "grok-4.5", + object: "response", + output: [ + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "ok", annotations: [] }], + id: "msg_test", + status: "completed", + }, + ], + usage: { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + cost_in_usd_ticks: 1, + }, + status: "completed", + }), + { headers: { "content-type": "application/json" } } + ) + ); + }, fetch) as typeof fetch; + + // Install mock as the base fetch so factory wrappers still run and we + // observe the final request body (including store injection). + return module.createXai({ ...options, fetch: mockFetch }); + }, + }; + }; + + try { + const result = await factory.createModel("xai:grok-4.5"); + expect(result.success).toBe(true); + if (!result.success) return; + + // Omit store in providerOptions: factory default injection must supply store=false. + await generateText({ + model: result.data, + prompt: "hi", + providerOptions: { + xai: { + reasoningEffort: "medium", + }, + }, + }); + + expect(capturedBody).toBeDefined(); + expect(capturedBody?.store).toBe(false); + // @ai-sdk/xai auto-includes encrypted reasoning when store=false. + expect(capturedBody?.include).toEqual( + expect.arrayContaining(["reasoning.encrypted_content"]) + ); + } finally { + PROVIDER_REGISTRY.xai = originalXaiRegistry; + } + }); + }); }); describe("ProviderModelFactory GitHub Copilot", () => { diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 347471a95c..81286d5aff 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -1086,17 +1086,6 @@ export class ProviderModelFactory { }; } - // xAI-specific: merge global store setting for Grok Responses ZDR orgs. - // Grok 4.5 defaults to store=true; ZDR teams need store=false or the API errors. - const configXAIStore = providersConfig.xai?.store; - if (providerName === "xai" && typeof configXAIStore === "boolean") { - muxProviderOptions ??= {}; - muxProviderOptions.xai = { - ...(muxProviderOptions.xai ?? {}), - store: muxProviderOptions.xai?.store ?? configXAIStore, - }; - } - let providerConfig = providersConfig[providerName] ?? {}; // Providers can be disabled in providers.jsonc without deleting credentials. @@ -1533,10 +1522,15 @@ export class ProviderModelFactory { ? provider.responses(modelId) : provider.chat(modelId); - // Inject configured xAI store as a request-level default so callers that - // omit providerOptions still honor global ZDR settings (mirrors OpenAI). - const configuredXAIStore = muxProviderOptions?.xai?.store; - if (typeof configuredXAIStore === "boolean") { + // Grok 4.5 Responses: force store=false by default so ZDR and non-ZDR share + // one path. buildProviderOptions already defaults this; inject here too so + // callers that omit providerOptions still get ZDR-safe requests. Explicit + // request-level store values win over the default. + if (isGrok45Model(capabilityModel)) { + const configuredXAIStore = + typeof muxProviderOptions?.xai?.store === "boolean" + ? muxProviderOptions.xai.store + : false; const injectStoreFlag = ( options: Parameters[0] ): Parameters[0] => { @@ -1546,6 +1540,7 @@ export class ProviderModelFactory { ...options, providerOptions: { ...options.providerOptions, + // Request-level store wins; otherwise force the ZDR-safe default. xai: { store: configuredXAIStore, ...xaiOpts }, }, }; diff --git a/src/node/services/providerService.test.ts b/src/node/services/providerService.test.ts index a45605048f..6eb5a78df5 100644 --- a/src/node/services/providerService.test.ts +++ b/src/node/services/providerService.test.ts @@ -257,25 +257,6 @@ describe("ProviderService.getConfig", () => { }); }); - it("surfaces store: false for xAI ZDR", () => { - withTempConfig((config, service) => { - config.saveProvidersConfig({ - xai: { - apiKey: "xai-key", - store: false, - }, - }); - expect(service.getConfig().xai.store).toBe(false); - - config.saveProvidersConfig({ - xai: { - apiKey: "xai-key", - }, - }); - expect(service.getConfig().xai.store).toBeUndefined(); - }); - }); - it("surfaces non-secret op:// API key references", () => { withTempConfig((config, service) => { const opRef = "op://Personal/Anthropic/credential"; diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index 74ea13e245..dcee9998d2 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -399,8 +399,9 @@ export class ProviderService { providerInfo.wireFormat = wireFormat; } - // OpenAI/xAI: response storage setting (required for ZDR on Responses API) - if ((provider === "openai" || provider === "xai") && typeof config.store === "boolean") { + // OpenAI-specific: response storage setting (required for ZDR). + // xAI Grok 4.5 always uses store=false in the request path (no settings surface). + if (provider === "openai" && typeof config.store === "boolean") { providerInfo.store = config.store; } diff --git a/tests/ipc/providers/xaiGrok45.test.ts b/tests/ipc/providers/xaiGrok45.test.ts index 67e13976dc..86ea57caf3 100644 --- a/tests/ipc/providers/xaiGrok45.test.ts +++ b/tests/ipc/providers/xaiGrok45.test.ts @@ -1,5 +1,8 @@ import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { isStreamEnd } from "@/common/orpc/types"; +import type { MuxMessage } from "@/common/types/message"; +import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; +import { HistoryService } from "@/node/services/historyService"; import { assertStreamSuccess, configureTestRetries, @@ -14,6 +17,42 @@ if (shouldRunIntegrationTests()) { validateApiKeys(["XAI_API_KEY"]); } +const DISABLE_TOOLS: ToolPolicy = [{ regex_match: ".*", action: "disable" }]; + +function hasXaiEncryptedReasoning(messages: MuxMessage[]): boolean { + for (const message of messages) { + if (message.role !== "assistant" || !Array.isArray(message.parts)) continue; + for (const part of message.parts) { + if (part.type !== "reasoning") continue; + const encrypted = part.providerOptions?.xai?.reasoningEncryptedContent; + if (typeof encrypted === "string" && encrypted.length > 0) { + return true; + } + } + } + return false; +} + +async function waitForTerminal( + collector: ReturnType, + timeoutMs: number +) { + const terminalEvent = await Promise.race([ + collector.waitForEvent("stream-end", timeoutMs), + collector.waitForEvent("stream-error", timeoutMs), + ]); + if (!terminalEvent) { + throw new Error("Expected terminal stream event from Grok 4.5"); + } + if (terminalEvent.type === "stream-error") { + throw new Error(`Grok 4.5 stream failed: ${terminalEvent.error}`); + } + if (!isStreamEnd(terminalEvent)) { + throw new Error(`Expected stream-end event, received ${terminalEvent.type}`); + } + return terminalEvent; +} + describeIntegration("xAI Grok 4.5 integration", () => { configureTestRetries(3); @@ -42,20 +81,7 @@ describeIntegration("xAI Grok 4.5 integration", () => { expect(result.success).toBe(true); - const terminalEvent = await Promise.race([ - collector.waitForEvent("stream-end", 60_000), - collector.waitForEvent("stream-error", 60_000), - ]); - if (!terminalEvent) { - throw new Error("Expected terminal stream event from Grok 4.5"); - } - if (terminalEvent.type === "stream-error") { - throw new Error(`Grok 4.5 stream failed: ${terminalEvent.error}`); - } - if (!isStreamEnd(terminalEvent)) { - throw new Error(`Expected stream-end event, received ${terminalEvent.type}`); - } - const streamEnd = terminalEvent; + const streamEnd = await waitForTerminal(collector, 60_000); assertStreamSuccess(collector); expect(streamEnd.metadata.model).toBe(KNOWN_MODELS.GROK_45.id); @@ -72,4 +98,85 @@ describeIntegration("xAI Grok 4.5 integration", () => { await cleanup(); } }, 90_000); + + test("multi-turn with default store=false keeps encrypted reasoning and continues cleanly", async () => { + // Grok 4.5 Responses always use store=false in Mux (ZDR-safe default). + // With store=false, xAI returns reasoning.encrypted_content which Mux must + // persist and replay; otherwise the second turn fails or loses quality. + const { env, workspaceId, cleanup } = await setupWorkspace("xai", "grok-4-5-zdr"); + const historyService = new HistoryService(env.config); + + try { + const firstCollector = createStreamCollector(env.orpc, workspaceId); + firstCollector.start(); + await firstCollector.waitForSubscription(); + + const firstResult = await sendMessageWithModel( + env, + workspaceId, + [ + "Think carefully about this secret codeword for the rest of the chat: MUXZDR42.", + "Do not mention the codeword yet.", + "Reply with exactly: READY", + ].join(" "), + KNOWN_MODELS.GROK_45.id, + { + thinkingLevel: "medium", + toolPolicy: DISABLE_TOOLS, + // Explicitly exercise the non-store path (also the product default). + providerOptions: { + xai: { + store: false, + }, + }, + } + ); + expect(firstResult.success).toBe(true); + + const firstEnd = await waitForTerminal(firstCollector, 90_000); + assertStreamSuccess(firstCollector); + expect(firstCollector.getDeltas().join("")).toMatch(/READY/i); + firstCollector.stop(); + + // Prove encrypted reasoning landed in persisted history under store=false. + const historyResult = await historyService.getHistoryFromLatestBoundary(workspaceId); + expect(historyResult.success).toBe(true); + if (!historyResult.success) { + throw new Error(historyResult.error); + } + expect(hasXaiEncryptedReasoning(historyResult.data)).toBe(true); + + // Second turn must succeed by replaying encrypted reasoning without server storage. + const secondCollector = createStreamCollector(env.orpc, workspaceId); + secondCollector.start(); + await secondCollector.waitForSubscription(); + + const secondResult = await sendMessageWithModel( + env, + workspaceId, + "Now reply with exactly the secret codeword and nothing else.", + KNOWN_MODELS.GROK_45.id, + { + thinkingLevel: "medium", + toolPolicy: DISABLE_TOOLS, + providerOptions: { + xai: { + store: false, + }, + }, + } + ); + expect(secondResult.success).toBe(true); + + await waitForTerminal(secondCollector, 90_000); + assertStreamSuccess(secondCollector); + + const secondText = secondCollector.getDeltas().join(""); + expect(secondText).toMatch(/MUXZDR42/); + expect(firstEnd.metadata.model).toBe(KNOWN_MODELS.GROK_45.id); + secondCollector.stop(); + } finally { + await cleanup(); + } + }, 180_000); }); From 061bac3effa021b8d435063e0ec47719592ec003 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 5 Aug 2026 18:32:15 -0500 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20use=20getStreamConten?= =?UTF-8?q?t=20in=20Grok=20ZDR=20multi-turn=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/ipc/providers/xaiGrok45.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/ipc/providers/xaiGrok45.test.ts b/tests/ipc/providers/xaiGrok45.test.ts index 86ea57caf3..3e3d92b6c9 100644 --- a/tests/ipc/providers/xaiGrok45.test.ts +++ b/tests/ipc/providers/xaiGrok45.test.ts @@ -92,7 +92,7 @@ describeIntegration("xAI Grok 4.5 integration", () => { | undefined; expect(typeof xaiMetadata?.costInUsdTicks).toBe("number"); expect(xaiMetadata?.costInUsdTicks).toBeGreaterThan(0); - expect(collector.getDeltas().join("").trim().length).toBeGreaterThan(0); + expect(collector.getStreamContent().trim().length).toBeGreaterThan(0); } finally { collector.stop(); await cleanup(); @@ -135,7 +135,7 @@ describeIntegration("xAI Grok 4.5 integration", () => { const firstEnd = await waitForTerminal(firstCollector, 90_000); assertStreamSuccess(firstCollector); - expect(firstCollector.getDeltas().join("")).toMatch(/READY/i); + expect(firstCollector.getStreamContent()).toMatch(/READY/i); firstCollector.stop(); // Prove encrypted reasoning landed in persisted history under store=false. @@ -171,8 +171,7 @@ describeIntegration("xAI Grok 4.5 integration", () => { await waitForTerminal(secondCollector, 90_000); assertStreamSuccess(secondCollector); - const secondText = secondCollector.getDeltas().join(""); - expect(secondText).toMatch(/MUXZDR42/); + expect(secondCollector.getStreamContent()).toMatch(/MUXZDR42/); expect(firstEnd.metadata.model).toBe(KNOWN_MODELS.GROK_45.id); secondCollector.stop(); } finally { From 431938eac78ba9019dcdff94fb38ea9886fb2d11 Mon Sep 17 00:00:00 2001 From: Ammar Date: Wed, 5 Aug 2026 18:36:31 -0500 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=A4=96=20fix:=20apply=20Grok=20store?= =?UTF-8?q?=3Dfalse=20default=20on=20gateway=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/providerModelFactory.ts | 79 ++++++++++++++++------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 81286d5aff..b1d68cd79c 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -211,6 +211,48 @@ export function resolveOpenAIWebSocketResponsesUrl(baseURL: unknown): string | u return url.toString(); } +/** + * Force Grok 4.5 Responses onto store=false by default (ZDR-safe). + * Applied for both direct xAI and gateway-routed Grok so callers that omit + * providerOptions (identity generation, memory harvest, headless tools) never + * hit the upstream store=true default. Explicit request-level store wins. + */ +function injectGrok45StoreDefault( + model: { + doStream: (options: never) => unknown; + doGenerate: (options: never) => unknown; + }, + configuredStore: unknown +): void { + const defaultStore = typeof configuredStore === "boolean" ? configuredStore : false; + interface CallOptions { + providerOptions?: Record; + } + const injectStoreFlag = (options: T): T => { + const xaiOpts = (options.providerOptions?.xai as Record | undefined) ?? {}; + return { + ...options, + providerOptions: { + ...options.providerOptions, + // Request-level store wins; otherwise force the ZDR-safe default. + xai: { store: defaultStore, ...xaiOpts }, + }, + }; + }; + + // LanguageModelV4 method types are invariant on options; cast through a local + // structural type so we can wrap doStream/doGenerate without dragging AI SDK + // generics into this factory helper. + const mutableModel = model as { + doStream: (options: CallOptions) => unknown; + doGenerate: (options: CallOptions) => unknown; + }; + const originalDoStream = mutableModel.doStream.bind(mutableModel); + const originalDoGenerate = mutableModel.doGenerate.bind(mutableModel); + mutableModel.doStream = (options) => originalDoStream(injectStoreFlag(options)); + mutableModel.doGenerate = (options) => originalDoGenerate(injectStoreFlag(options)); +} + /** * Add xAI's service_tier request field until @ai-sdk/xai exposes it directly. * Priority Processing is a scheduling/billing choice, not a separate model id. @@ -1524,32 +1566,9 @@ export class ProviderModelFactory { // Grok 4.5 Responses: force store=false by default so ZDR and non-ZDR share // one path. buildProviderOptions already defaults this; inject here too so - // callers that omit providerOptions still get ZDR-safe requests. Explicit - // request-level store values win over the default. + // callers that omit providerOptions still get ZDR-safe requests. if (isGrok45Model(capabilityModel)) { - const configuredXAIStore = - typeof muxProviderOptions?.xai?.store === "boolean" - ? muxProviderOptions.xai.store - : false; - const injectStoreFlag = ( - options: Parameters[0] - ): Parameters[0] => { - const xaiOpts = - (options.providerOptions?.xai as Record | undefined) ?? {}; - return { - ...options, - providerOptions: { - ...options.providerOptions, - // Request-level store wins; otherwise force the ZDR-safe default. - xai: { store: configuredXAIStore, ...xaiOpts }, - }, - }; - }; - - const originalDoStream = model.doStream.bind(model); - const originalDoGenerate = model.doGenerate.bind(model); - model.doStream = (options) => originalDoStream(injectStoreFlag(options)); - model.doGenerate = (options) => originalDoGenerate(injectStoreFlag(options)); + injectGrok45StoreDefault(model, muxProviderOptions?.xai?.store); } return Ok(model); @@ -1812,6 +1831,16 @@ export class ProviderModelFactory { model.doGenerate = (options) => originalDoGenerate(injectStoreFlag(options)); } + // Gateway-routed Grok 4.5 must get the same store=false default as direct xAI. + // Route form is mux-gateway:xai/; capability lookup uses canonical xai:id. + if (modelId.startsWith("xai/")) { + const gatewayGrokModel = `xai:${modelId.slice("xai/".length)}`; + const capabilityModel = resolveModelForMetadata(gatewayGrokModel, providersConfig); + if (isGrok45Model(capabilityModel)) { + injectGrok45StoreDefault(model, muxProviderOptions?.xai?.store); + } + } + return Ok(model); }