diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 7d66f6985d..e8f2f547a5 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -97,7 +97,7 @@ describe("XAIHandler", () => { const stream = handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: xaiDefaultModelId, instructions: "test prompt", @@ -105,7 +105,6 @@ describe("XAIHandler", () => { store: false, include: ["reasoning.encrypted_content"], }), - undefined, ) }) @@ -222,7 +221,7 @@ describe("XAIHandler", () => { }) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ tools: [ expect.objectContaining({ @@ -235,7 +234,6 @@ describe("XAIHandler", () => { tool_choice: "auto", parallel_tool_calls: true, }), - undefined, ) }) @@ -260,11 +258,10 @@ describe("XAIHandler", () => { }) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ parallel_tool_calls: false, }), - undefined, ) }) @@ -274,12 +271,11 @@ describe("XAIHandler", () => { const stream = handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ max_output_tokens: 65_536, temperature: 0, }), - undefined, ) }) @@ -305,6 +301,207 @@ describe("XAIHandler", () => { expect(result).toBe(expectedResponse) }) + it("createMessage should map a forced tool_choice to the Responses API shape", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { type: "function", function: { name: "test_tool" } }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { type: "function", name: "test_tool" }, + }), + ) + }) + + it("createMessage should flatten allowed_tools entries to the Responses API shape", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { + type: "allowed_tools", + allowed_tools: { + mode: "required", + tools: [ + { type: "function", function: { name: "test_tool" } }, + { type: "mcp", server_label: "deepwiki" }, + ], + }, + }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "test_tool" }, + { type: "mcp", server_label: "deepwiki" }, + ], + }, + }), + ) + }) + + it("createMessage should reject an allowed_tools function entry with a malformed name", async () => { + // The Chat Completions allowlist type accepts open records, so a + // function entry can carry a missing or non-string name. It must be + // rejected at the boundary rather than forwarded in the nested Chat + // Completions shape, which the Responses API cannot express. + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + const callsBefore = mockResponsesCreate.mock.calls.length + + const malformedEntries = [ + { type: "function", function: { name: null } }, + { type: "function", function: null }, + { type: "function", function: () => 1 }, + { type: "function", function: { name: 123 } }, + ] + for (const entry of malformedEntries) { + // The mapping throws during request construction; the abort bridge must not have + // registered a listener yet, or a malformed tool choice would leak it. + const abortController = new AbortController() + const addListenerSpy = vi.spyOn(abortController.signal, "addEventListener") + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + abortSignal: abortController.signal, + tools: testTools, + tool_choice: { + type: "allowed_tools", + allowed_tools: { + mode: "required", + tools: [entry, { type: "mcp", server_label: "deepwiki" }], + }, + }, + }) + await expect(stream.next()).rejects.toThrow( + 'allowed_tools entry of type "function" is missing a valid function name', + ) + expect(addListenerSpy).not.toHaveBeenCalled() + addListenerSpy.mockRestore() + } + expect(mockResponsesCreate.mock.calls.length).toBe(callsBefore) + }) + + it("createMessage should map a custom tool_choice to the Responses API shape", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { type: "custom", custom: { name: "custom_tool" } }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { type: "custom", name: "custom_tool" }, + }), + ) + }) + + it("createMessage should pass through unmappable allowed_tools entries unchanged", async () => { + const testTools = [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] }, + }, + }, + ] + + // Entries that are not function references pass through unchanged: a + // non-function type carrying a function ref must not be rewritten. + // Malformed function entries (null ref, function value, non-string name) + // are rejected at the boundary; see the dedicated rejection test. + const entries = [{ type: "mcp", function: { name: "leaky" } }] + + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + tools: testTools, + tool_choice: { type: "allowed_tools", allowed_tools: { mode: "auto", tools: entries } }, + }) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: entries, + }, + }), + ) + }) + + it("createMessage should send max_output_tokens and temperature for the default model", async () => { + mockResponsesCreate.mockResolvedValueOnce(asyncStreamFrom([])) + + const stream = handler.createMessage("test prompt", []) + await stream.next() + + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + max_output_tokens: 65_536, + temperature: 0, + }), + ) + }) + it("should handle errors in completePrompt", async () => { const errorMessage = "API error" mockResponsesCreate.mockRejectedValueOnce(new Error(errorMessage)) @@ -346,9 +543,9 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { abortSignal: controller.signal }) - expect(mockResponsesCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { - signal: controller.signal, - }) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ model: expect.any(String) })) + const requestOptions = mockResponsesCreate.mock.calls[0]?.[1] as { signal?: AbortSignal } | undefined + expect(requestOptions?.signal).toBe(controller.signal) }) it("completePrompt should work without options (backward compatible)", async () => { @@ -356,10 +553,7 @@ describe("XAIHandler", () => { const result = await handler.completePrompt("test prompt") expect(result).toBe("response") - expect(mockResponsesCreate).toHaveBeenCalledWith( - expect.objectContaining({ model: expect.any(String) }), - undefined, - ) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ model: expect.any(String) })) }) it("completePrompt should send the full Responses API request body", async () => { @@ -367,14 +561,11 @@ describe("XAIHandler", () => { await handler.completePrompt("test prompt") - expect(mockResponsesCreate).toHaveBeenCalledWith( - { - model: xaiDefaultModelId, - input: [{ role: "user", content: [{ type: "input_text", text: "test prompt" }] }], - store: false, - }, - undefined, - ) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual({ + model: xaiDefaultModelId, + input: [{ role: "user", content: [{ type: "input_text", text: "test prompt" }] }], + store: false, + }) }) it("completePrompt should pass timeout through to client", async () => { @@ -382,10 +573,10 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) - expect(mockResponsesCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { - signal: controller.signal, - timeout: 5000, - }) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ model: expect.any(String) })) + const requestOptions = mockResponsesCreate.mock.calls[0]?.[1] as + { signal?: AbortSignal; timeout?: number } | undefined + expect(requestOptions).toMatchObject({ signal: controller.signal, timeout: 5000 }) }) it.each([ @@ -400,9 +591,10 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { timeoutMs: input }) - expect(mockResponsesCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { - timeout: forwarded, - }) + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ model: expect.any(String) }), + ) + expect(mockResponsesCreate.mock.calls[0]?.[1]).toMatchObject({ timeout: forwarded }) }, ) @@ -414,10 +606,11 @@ describe("XAIHandler", () => { mockResponsesCreate.mockClear() mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { timeoutMs }) - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: expect.any(String) }), - undefined, ) + // No usable timeout and no signal means the SDK options argument is omitted entirely. + expect(mockResponsesCreate.mock.calls[0]?.[1]).toBeUndefined() }, ) @@ -432,13 +625,12 @@ describe("XAIHandler", () => { const stream = miniModelHandler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ reasoning: expect.objectContaining({ effort: "high", }), }), - undefined, ) }) @@ -452,14 +644,13 @@ describe("XAIHandler", () => { const stream = grok45Handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: "grok-4.5", reasoning: expect.objectContaining({ effort: "high", }), }), - undefined, ) }) @@ -474,14 +665,13 @@ describe("XAIHandler", () => { const stream = grok45Handler.createMessage("test prompt", []) await stream.next() - expect(mockResponsesCreate).toHaveBeenCalledWith( + expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: "grok-4.5", reasoning: expect.objectContaining({ effort: "low", }), }), - undefined, ) }) diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index f09f3df252..29591e01da 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -70,6 +70,61 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler * Uses base provider's convertToolSchemaForOpenAI() for schema hardening * (additionalProperties: false, ensureAllRequired) and handles MCP tools. */ + /** + * Map a Chat Completions tool choice to the Responses API shape so TypeScript + * validates the provider payload (the APIs use different object forms for the + * named-tool choices: Chat Completions nests the name under `function`/`custom`, + * Responses API puts it at the top level). String options (auto/required/none) + * are identical in both APIs and pass through unchanged. `allowed_tools` + * entries keep their allowlist mode, but Chat Completions function references + * ({ type: "function", function: { name } }) are flattened to the Responses + * API shape ({ type: "function", name }); other entry types pass through + * unchanged. + */ + private mapToolChoice( + toolChoice: NonNullable, + ): OpenAI.Responses.ResponseCreateParamsStreaming["tool_choice"] { + if (typeof toolChoice === "string") { + return toolChoice + } + switch (toolChoice.type) { + case "function": + return { type: "function", name: toolChoice.function.name } + case "custom": + return { type: "custom", name: toolChoice.custom.name } + case "allowed_tools": + return { + type: "allowed_tools", + mode: toolChoice.allowed_tools.mode, + tools: toolChoice.allowed_tools.tools.map((entry) => { + // Chat Completions allowlist entries nest the function reference + // ({ type: "function", function: { name } }); the Responses API + // expects the name at the top level ({ type: "function", name }). + // The Chat Completions type allows open records, so a function + // entry can arrive malformed (missing or non-string name). + // Forwarding it unchanged would send the nested Chat Completions + // shape to the Responses API, which cannot express that allowlist + // entry, and how xAI handles the malformed payload is unspecified; + // reject it at the boundary instead. + if (entry["type"] === "function") { + const functionRef = entry["function"] + const name = + functionRef != null && typeof functionRef === "object" + ? (functionRef as Record)["name"] + : undefined + if (typeof name !== "string") { + throw new Error( + 'xAI: allowed_tools entry of type "function" is missing a valid function name', + ) + } + return { type: "function", name } + } + return entry + }), + } + } + } + private mapResponseTools(tools?: any[]): any[] | undefined { const converted = this.convertToolsForOpenAI(tools) if (!converted?.length) { @@ -101,18 +156,11 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler // Convert directly from Anthropic format to Responses API input format const input = convertToResponsesApiInput(messages) const responseTools = this.mapResponseTools(metadata?.tools) - - // Bridge the external abort signal from request metadata into a per-request - // controller so the SDK call is cancelled when the owning request is aborted - // (or when the signal is already aborted). Without an external signal the - // client-level timeout configured in the constructor remains the only - // cancellation mechanism, preserving the existing behavior. - const externalAbortSignal = metadata?.abortSignal - const { signal: abortSignal, dispose: removeExternalAbortListener } = - bridgeExternalAbortSignal(externalAbortSignal) + const toolChoice = metadata?.tool_choice + const parallelToolCalls = metadata?.parallelToolCalls // Build request options - const requestBody: Record = { + const requestBody: OpenAI.Responses.ResponseCreateParamsStreaming = { model: model.id, instructions: systemPrompt, input: input, @@ -121,19 +169,16 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler include: ["reasoning.encrypted_content"], } - if (model.maxTokens) { - requestBody.max_output_tokens = model.maxTokens - } - - if (model.temperature !== undefined) { - requestBody.temperature = model.temperature - } + // Model params are always resolved by getModel(); send them unconditionally. + requestBody.max_output_tokens = model.maxTokens + requestBody.temperature = model.temperature if (responseTools) { requestBody.tools = responseTools - // Cast tool_choice since metadata uses Chat Completions types but Responses API has its own type - requestBody.tool_choice = (metadata?.tool_choice ?? "auto") as any - requestBody.parallel_tool_calls = metadata?.parallelToolCalls ?? true + // Metadata carries a Chat Completions tool choice; the Responses API + // uses its own shape, so map it explicitly instead of casting. + requestBody.tool_choice = this.mapToolChoice(toolChoice === undefined ? "auto" : toolChoice) + requestBody.parallel_tool_calls = parallelToolCalls ?? true } // Pass reasoning effort for models that support it (e.g., grok-4.5, grok-3-mini). @@ -143,6 +188,14 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler requestBody.reasoning = { effort: model.reasoning.reasoning_effort } } + // Bridge the external abort signal only after every throw-prone part of the request + // construction (notably mapToolChoice, which rejects malformed allowed_tools entries) + // has run: the bridge's cleanup covers the request below, not construction failures, + // so registering earlier would leak a listener on metadata.abortSignal. + const externalAbortSignal = metadata?.abortSignal + const { signal: abortSignal, dispose: removeExternalAbortListener } = + bridgeExternalAbortSignal(externalAbortSignal) + try { let stream: AsyncIterable try { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 8a9e1fa4df..51073af4c4 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -446,7 +446,7 @@ }, "api/providers/xai.ts": { "@typescript-eslint/no-explicit-any": { - "count": 7 + "count": 5 } }, "api/transform/__tests__/ai-sdk.spec.ts": {