From c547923dddc2041ca0f65e2818087ed718196ed1 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 10:52:02 +0800 Subject: [PATCH 1/6] refactor(providers): map xAI tool_choice to the Responses API shape with a typed request body Extracted from the abort-signal series unit (PR 1293) at reviewer request so that unit stays abort-wiring only: - xai: replace the `as any` tool_choice cast with an explicit mapToolChoice mapping (Chat Completions nests named-tool references under function/custom; the Responses API puts the name at the top level; allowed_tools entries are flattened). - xai: type the request body as ResponseCreateParamsStreaming (drops five lint suppressions) and send max_output_tokens/temperature unconditionally - getModel() always resolves them. - anthropic-vertex: drop the no-op content_block_stop case (the comment itself noted no action is needed; NativeToolCallParser handles tool-call completion). Verification: 75/75 green in xai.spec.ts + anthropic-vertex.spec.ts; tsc --noEmit 0 errors; eslint --max-warnings=0 clean on touched files (xai.ts suppression count 7 -> 2); prettier clean. --- src/api/providers/__tests__/xai.spec.ts | 158 ++++++++++++++++++++++++ src/api/providers/anthropic-vertex.ts | 7 -- src/api/providers/xai.ts | 73 ++++++++--- src/eslint-suppressions.json | 2 +- 4 files changed, 216 insertions(+), 24 deletions(-) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 366898e6bc..ff5c561b33 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -240,6 +240,164 @@ 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).toHaveBeenCalledWith( + 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).toHaveBeenCalledWith( + expect.objectContaining({ + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "test_tool" }, + { type: "mcp", server_label: "deepwiki" }, + ], + }, + }), + ) + }) + + 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).toHaveBeenCalledWith( + 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 fail the function-name guard must pass through unchanged: a + // non-function type carrying a function ref, a null ref, a function value + // (typeof "function", not "object", whose .name is still a string), and an + // object whose name is not a string. + const probeFunction = () => 1 + const entries = [ + { type: "mcp", function: { name: "leaky" } }, + { type: "function", function: null }, + { type: "function", function: probeFunction }, + { type: "function", function: { name: 123 } }, + ] + + 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).toHaveBeenCalledWith( + 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).toHaveBeenCalledWith( + 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)) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 5d21ac3ffd..a1daca33f9 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -209,13 +209,6 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple break } - case "content_block_stop": { - // Block complete - no action needed for now. - // NativeToolCallParser handles tool call completion - // Note: Signature for multi-turn thinking would require using stream.finalMessage() - // after iteration completes, which requires restructuring the streaming approach. - break - } } } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 189ec4e9ed..324c8f5e63 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -63,6 +63,51 @@ 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 }). + const functionRef = entry["function"] + if ( + entry["type"] === "function" && + functionRef != null && + typeof functionRef === "object" && + typeof (functionRef as Record)["name"] === "string" + ) { + return { type: "function", name: (functionRef as Record)["name"] } + } + return entry + }), + } + } + } + private mapResponseTools(tools?: any[]): any[] | undefined { const converted = this.convertToolsForOpenAI(tools) if (!converted?.length) { @@ -94,9 +139,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) + 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, @@ -105,19 +152,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). @@ -127,12 +171,9 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler requestBody.reasoning = { effort: model.reasoning.reasoning_effort } } - let stream: AsyncIterable + let stream: AsyncIterable try { - stream = (await this.client.responses.create({ - ...requestBody, - stream: true, - } as any)) as unknown as AsyncIterable + stream = await this.client.responses.create(requestBody) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 10e1c94931..45b1a32d72 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -451,7 +451,7 @@ }, "api/providers/xai.ts": { "@typescript-eslint/no-explicit-any": { - "count": 7 + "count": 2 } }, "api/transform/__tests__/ai-sdk.spec.ts": { From 177ee9bca514c22a1fafbba46b97b0270ce10646 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 12:38:00 +0800 Subject: [PATCH 2/6] Address review: reject malformed allowed_tools function entries Chat Completions allowlist entries are open records, so a function entry can arrive with a missing or non-string name. Forwarding such an entry 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. mapToolChoice now rejects function entries without a valid string name at the boundary (before responses.create); non-function entries still pass through unchanged. The pass-through test now pins only the non-function case; a new test rejects four malformed function-entry shapes (null name, null ref, function value, non-string name) and asserts no request is issued (negative control verified: reverting the throw to a pass-through fails the new test). Verification at this head: 25/25 green in xai.spec.ts; tsc --noEmit 0 errors; eslint --max-warnings=0 clean on touched files; prettier clean. --- src/api/providers/__tests__/xai.spec.ts | 58 ++++++++++++++++++++----- src/api/providers/xai.ts | 26 +++++++---- 2 files changed, 65 insertions(+), 19 deletions(-) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index ff5c561b33..213ca942e8 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -312,6 +312,48 @@ describe("XAIHandler", () => { ) }) + 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) { + const stream = handler.createMessage("test prompt", [], { + taskId: "test-task-id", + 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(mockResponsesCreate.mock.calls.length).toBe(callsBefore) + }) + it("createMessage should map a custom tool_choice to the Responses API shape", async () => { const testTools = [ { @@ -352,17 +394,11 @@ describe("XAIHandler", () => { }, ] - // Entries that fail the function-name guard must pass through unchanged: a - // non-function type carrying a function ref, a null ref, a function value - // (typeof "function", not "object", whose .name is still a string), and an - // object whose name is not a string. - const probeFunction = () => 1 - const entries = [ - { type: "mcp", function: { name: "leaky" } }, - { type: "function", function: null }, - { type: "function", function: probeFunction }, - { type: "function", function: { name: 123 } }, - ] + // 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([])) diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 324c8f5e63..23b7ae6798 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -93,14 +93,24 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler // 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 }). - const functionRef = entry["function"] - if ( - entry["type"] === "function" && - functionRef != null && - typeof functionRef === "object" && - typeof (functionRef as Record)["name"] === "string" - ) { - return { type: "function", name: (functionRef as Record)["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 }), From e6dccda2b9dee1be89d4e999d6d2e29eb40236f0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:57:38 +0800 Subject: [PATCH 3/6] chore(lint): take upstream baseline suppressions for xai.ts The merge brings main xai responses-create code whose as-any casts main suppresses (count 7 in upstream eslint-suppressions.json). Locally the auto-merge kept the older count 2, so compile lint flagged the inherited casts. Use the upstream baseline instead of leaving the branch red. --- src/eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 42e2d03db1..8a9e1fa4df 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": 2 + "count": 7 } }, "api/transform/__tests__/ai-sdk.spec.ts": { From 5266d79531c371e5d641854e8210a5baacd133e0 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:02:57 +0800 Subject: [PATCH 4/6] chore(lint): right-size the xai suppression to the merged any count The merge keeps five as-any occurrences in xai.ts (two in the mapResponseTools signature, three in the responses create call); the upstream baseline of seven would leave stale suppressions, which eslint rejects. Set the count to the actual five. --- src/eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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": { From a203c7a912c58be15928d76a6baa9f2eef6fcc55 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:13:28 +0800 Subject: [PATCH 5/6] test(xai): assert request options without the extra call-argument shape The merge with main makes responses.create take a second options argument. Rewriting the assertions to inspect mock.calls[0][0] left the old second expectation argument in place, which failed type-check (toEqual takes one argument). Each assertion now checks the request body and the separate options argument explicitly (signal/timeout, forwarded timeout, absent options). xai.spec.ts 49 passed; tsc clean for the file. --- src/api/providers/__tests__/xai.spec.ts | 46 +++++++++---------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index f1ca0294eb..9970e2d200 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -105,7 +105,6 @@ describe("XAIHandler", () => { store: false, include: ["reasoning.encrypted_content"], }), - undefined, ) }) @@ -235,7 +234,6 @@ describe("XAIHandler", () => { tool_choice: "auto", parallel_tool_calls: true, }), - undefined, ) }) @@ -264,7 +262,6 @@ describe("XAIHandler", () => { expect.objectContaining({ parallel_tool_calls: false, }), - undefined, ) }) @@ -279,7 +276,6 @@ describe("XAIHandler", () => { max_output_tokens: 65_536, temperature: 0, }), - undefined, ) }) @@ -540,9 +536,9 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { abortSignal: controller.signal }) - expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(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 () => { @@ -550,10 +546,7 @@ describe("XAIHandler", () => { const result = await handler.completePrompt("test prompt") expect(result).toBe("response") - expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( - 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 () => { @@ -561,14 +554,11 @@ describe("XAIHandler", () => { await handler.completePrompt("test prompt") - expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( - { - 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 () => { @@ -576,10 +566,10 @@ describe("XAIHandler", () => { mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) - expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual(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([ @@ -596,10 +586,8 @@ describe("XAIHandler", () => { await handler.completePrompt("test prompt", { timeoutMs: input }) expect(mockResponsesCreate.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ model: expect.any(String) }), - { - timeout: forwarded, - }, ) + expect(mockResponsesCreate.mock.calls[0]?.[1]).toMatchObject({ timeout: forwarded }) }, ) @@ -613,8 +601,9 @@ describe("XAIHandler", () => { await handler.completePrompt("test prompt", { timeoutMs }) 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() }, ) @@ -635,7 +624,6 @@ describe("XAIHandler", () => { effort: "high", }), }), - undefined, ) }) @@ -656,7 +644,6 @@ describe("XAIHandler", () => { effort: "high", }), }), - undefined, ) }) @@ -678,7 +665,6 @@ describe("XAIHandler", () => { effort: "low", }), }), - undefined, ) }) From 682665e3cd5939fcabe0f2ac9e1dcb25904efa72 Mon Sep 17 00:00:00 2001 From: easonLiangWorldedtech <136036952+easonLiangWorldedtech@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:56:26 +0800 Subject: [PATCH 6/6] fix(xai): bridge the abort signal only after request construction Review follow-up: mapToolChoice throws for malformed allowed_tools entries, and the abort bridge cleanup only covers the request that follows. The bridge now registers after the whole request body (including the tool-choice mapping) is built, so a construction failure cannot leave a listener on metadata.abortSignal. The malformed-entry test now passes an abortSignal and asserts addEventListener was never called. xai.spec.ts 49 passed; tsc clean. --- src/api/providers/__tests__/xai.spec.ts | 7 +++++++ src/api/providers/xai.ts | 17 ++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 9970e2d200..e8f2f547a5 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -397,8 +397,13 @@ describe("XAIHandler", () => { { 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", @@ -411,6 +416,8 @@ describe("XAIHandler", () => { 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) }) diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 89802b0609..29591e01da 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -159,15 +159,6 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler const toolChoice = metadata?.tool_choice const parallelToolCalls = metadata?.parallelToolCalls - // 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) - // Build request options const requestBody: OpenAI.Responses.ResponseCreateParamsStreaming = { model: model.id, @@ -197,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 {