Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions src/browser/features/Settings/Sections/ProvidersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,10 @@ export function ProvidersSection() {
const [openaiServiceTierSelectOverride, setOpenaiServiceTierSelectOverride] =
useState<OpenAIServiceTierSelectValue | null>(null);
const [xaiServiceTierSaving, setXAIServiceTierSaving] = useState(false);
// 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 routing = useRouting();

Expand Down Expand Up @@ -2639,17 +2643,30 @@ export function ProvidersSection() {
</div>
<Select
value={config?.openai?.store === false ? "disabled" : "enabled"}
disabled={openaiStoreSaving}
onValueChange={(next) => {
if (!api) return;
if (!api || openaiStoreSaving) return;
if (next !== "enabled" && next !== "disabled") return;

const store = next === "disabled" ? false : undefined;
updateOptimistically("openai", { store });
void api.providers.setProviderConfig({
provider: "openai",
keyPath: ["store"],
value: next === "disabled" ? false : "",
});
setOpenAIStoreSaving(true);
void api.providers
.setProviderConfig({
provider: "openai",
keyPath: ["store"],
value: next === "disabled" ? false : "",
})
.then(
(result) => {
if (result.success) {
updateOptimistically("openai", { store });
return undefined;
}
return refresh();
},
() => refresh()
)
.finally(() => setOpenAIStoreSaving(false));
}}
>
<SelectTrigger className="w-40">
Expand Down
1 change: 1 addition & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
6 changes: 6 additions & 0 deletions src/common/schemas/providerOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}),
// 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. Grok 4.5 defaults to false (ZDR-safe); set true only to opt back into server storage.",
}),
searchParameters: z
.object({
mode: z.enum(["auto", "off", "on"]),
Expand Down
14 changes: 12 additions & 2 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};
}

Expand Down
37 changes: 35 additions & 2 deletions src/common/utils/ai/providerOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
});

Expand All @@ -1660,9 +1661,41 @@ describe("buildProviderOptions - xAI", () => {
).toEqual({
xai: {
reasoningEffort: "high",
store: false,
},
});
});

test("defaults Grok 4.5 store to false without an explicit override", () => {
expect(
buildProviderOptions("xai:grok-4.5", "medium", undefined, undefined, { xai: {} })
).toEqual({
xai: {
reasoningEffort: "medium",
store: false,
},
});
});

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<string, unknown> }).xai;
expect(xai).toBeDefined();
expect("store" in xai!).toBe(false);
});
});

describe("buildRequestHeaders", () => {
Expand Down
25 changes: 22 additions & 3 deletions src/common/utils/ai/providerOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<XaiResponsesProviderOptions, "store">;

/**
* Provider-specific options structure for AI SDK
*/
Expand All @@ -82,7 +92,7 @@ type ProviderOptions =
| { google: GoogleGenerativeAIProviderOptions }
| { openrouter: OpenRouterReasoningOptions }
| { moonshotai: MoonshotAIProviderOptions }
| { xai: XaiProviderOptions }
| { xai: XaiBuiltProviderOptions }
| { "github-copilot": OpenAICompatibleGatewayProviderOptions }
| Record<string, never>; // Empty object for unsupported providers

Expand Down Expand Up @@ -545,6 +555,7 @@ export function buildProviderOptions(
const {
serviceTier: _serviceTier,
searchParameters,
store,
...overrides
} = muxProviderOptions?.xai ?? {};
const isGrok45 = isGrok45Model(capabilityModel);
Expand All @@ -561,17 +572,25 @@ 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 }),
...(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 && {
searchParameters: searchParameters ?? defaultSearchParameters,
}),
},
} satisfies { xai: XaiProviderOptions };
} satisfies { xai: XaiBuiltProviderOptions };
log.debug("buildProviderOptions: Returning xAI options", options);
return options;
}
Expand Down
80 changes: 80 additions & 0 deletions src/node/services/providerModelFactory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | 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<string, unknown>;
}
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", () => {
Expand Down
67 changes: 64 additions & 3 deletions src/node/services/providerModelFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}
const injectStoreFlag = <T extends CallOptions>(options: T): T => {
const xaiOpts = (options.providerOptions?.xai as Record<string, unknown> | 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.
Expand Down Expand Up @@ -1518,9 +1560,18 @@ 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);

// 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.
if (isGrok45Model(capabilityModel)) {
Comment thread
ammar-agent marked this conversation as resolved.
injectGrok45StoreDefault(model, muxProviderOptions?.xai?.store);
}

return Ok(model);
}

// Handle Ollama provider
Expand Down Expand Up @@ -1780,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/<model>; 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);
}

Expand Down
Loading
Loading