From c547923dddc2041ca0f65e2818087ed718196ed1 Mon Sep 17 00:00:00 2001 From: Eason Liang Date: Tue, 13 Oct 2026 10:52:02 +0800 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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/7] 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 { From 93db1661bb45d40e39bcc2b9e726e558a84d867d Mon Sep 17 00:00:00 2001 From: Elliott de Launay Date: Sat, 31 Oct 2026 15:09:32 +0000 Subject: [PATCH 7/7] chore(lint): flag unneeded type assertions in api/ --- src/api/providers/anthropic-vertex.ts | 24 ++++++++++++------------ src/api/providers/anthropic.ts | 4 ++-- src/api/providers/nanogpt.ts | 6 +----- src/api/providers/openai-codex.ts | 2 +- src/api/providers/openai-native.ts | 7 +++---- src/api/providers/xai.ts | 18 +++++++++--------- src/api/transform/model-params.ts | 2 +- src/api/transform/openai-format.ts | 2 +- src/api/transform/reasoning.ts | 5 ++--- src/eslint-suppressions.json | 2 +- src/eslint.config.mjs | 15 +++++++++++++++ 11 files changed, 48 insertions(+), 39 deletions(-) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 87da090346..c1ffdc6d7f 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -143,7 +143,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple for await (const chunk of stream) { switch (chunk.type) { case "message_start": { - const usage = chunk.message!.usage + const usage = chunk.message.usage // `output_tokens_details.thinking_tokens` decomposes the billed // output into internal reasoning tokens; surfaced for per-turn @@ -155,19 +155,19 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple cacheWriteTokens: usage.cache_creation_input_tokens || undefined, cacheReadTokens: usage.cache_read_input_tokens || undefined, ...(typeof usage.output_tokens_details?.thinking_tokens === "number" - ? { reasoningTokens: usage.output_tokens_details!.thinking_tokens } + ? { reasoningTokens: usage.output_tokens_details.thinking_tokens } : {}), } break } case "message_delta": { - const deltaThinkingTokens = chunk.usage!.output_tokens_details?.thinking_tokens + const deltaThinkingTokens = chunk.usage.output_tokens_details?.thinking_tokens yield { type: "usage", inputTokens: 0, - outputTokens: chunk.usage!.output_tokens || 0, + outputTokens: chunk.usage.output_tokens || 0, ...(typeof deltaThinkingTokens === "number" ? { reasoningTokens: deltaThinkingTokens } : {}), @@ -176,17 +176,17 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple break } case "content_block_start": { - switch (chunk.content_block!.type) { + switch (chunk.content_block.type) { case "text": { - if (chunk.index! > 0) { + if (chunk.index > 0) { yield { type: "text", text: "\n" } } - yield { type: "text", text: chunk.content_block!.text } + yield { type: "text", text: chunk.content_block.text } break } case "thinking": { - if (chunk.index! > 0) { + if (chunk.index > 0) { yield { type: "reasoning", text: "\n" } } @@ -198,8 +198,8 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple yield { type: "tool_call_partial", index: chunk.index, - id: chunk.content_block!.id, - name: chunk.content_block!.name, + id: chunk.content_block.id, + name: chunk.content_block.name, arguments: undefined, } break @@ -209,9 +209,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple break } case "content_block_delta": { - switch (chunk.delta!.type) { + switch (chunk.delta.type) { case "text_delta": { - yield { type: "text", text: chunk.delta!.text } + yield { type: "text", text: chunk.delta.text } break } case "thinking_delta": { diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index f00b49cd08..1b0174b2c2 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -32,7 +32,7 @@ import { // Pre-sorted list of known Anthropic model IDs (lowercased) by length (descending) for case-insensitive substring matching. const ANTHROPIC_MODEL_IDS_SORTED_LOWER = (Object.keys(anthropicModels) as AnthropicModelId[]) .map((id) => id.toLowerCase()) - .sort((a, b) => b.length - a.length) as string[] + .sort((a, b) => b.length - a.length) // Original-case mapping: lowercase key → original AnthropicModelId for lookup. const ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL = Object.fromEntries( @@ -400,7 +400,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa return anthropicModels[anthropicDefaultModelId] } - const originalId = ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL[matchedLower] as AnthropicModelId + const originalId = ANTHROPIC_MODEL_ID_LOWER_TO_ORIGINAL[matchedLower] return anthropicModels[originalId] } diff --git a/src/api/providers/nanogpt.ts b/src/api/providers/nanogpt.ts index 58cb29ec6e..4fe1c7b49a 100644 --- a/src/api/providers/nanogpt.ts +++ b/src/api/providers/nanogpt.ts @@ -9,7 +9,6 @@ import { providerIdentifiers, type ModelInfo, type ModelRecord, - type NanoGptRoutingPreference, type ReasoningEffortExtended, } from "@roo-code/types" @@ -115,10 +114,7 @@ export class NanoGptHandler extends RouterProvider implements SingleCompletionHa } private getRequestModelId(canonicalModelId: string): string { - return applyNanoGptRoutingPreference( - canonicalModelId, - this.options.nanoGptRoutingPreference as NanoGptRoutingPreference | undefined, - ) + return applyNanoGptRoutingPreference(canonicalModelId, this.options.nanoGptRoutingPreference) } private createSafeError(operation: string, error: unknown): Error { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index e77741ad9e..67e177987d 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -627,7 +627,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion if (block.type === "text") { content.push({ type: "input_text", text: block.text }) } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam + const image = block if (image.source.type === "base64") { const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` content.push({ type: "input_image", image_url: imageUrl }) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index fbbecf5760..9711156347 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -145,8 +145,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0 // Resolve effective tier: prefer actual tier from response; otherwise requested tier - const effectiveTier = - this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined + const effectiveTier = this.lastServiceTier || this.options.openAiNativeServiceTier || undefined const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier) // Pass total input tokens directly to calculateApiCostOpenAI @@ -486,7 +485,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio if (block.type === "text") { content.push({ type: "input_text", text: block.text }) } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam + const image = block if (image.source.type === "base64") { const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` content.push({ type: "input_image", image_url: imageUrl }) @@ -1551,7 +1550,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio // Include text.verbosity only when the model explicitly supports it if (model.info.supportsVerbosity === true) { - requestBody.text = { verbosity: (verbosity || "medium") as VerbosityLevel } + requestBody.text = { verbosity: verbosity || "medium" } } // Enable extended prompt cache retention for eligible models diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 29591e01da..e3c50adb33 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -62,14 +62,6 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler return { id, info, ...params } } - /** - * Convert tools from OpenAI Chat Completions format to Responses API format. - * Chat Completions: { type: "function", function: { name, description, parameters } } - * Responses API: { type: "function", name, description, parameters } - * - * 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 @@ -125,6 +117,14 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler } } + /** + * Convert tools from OpenAI Chat Completions format to Responses API format. + * Chat Completions: { type: "function", function: { name, description, parameters } } + * Responses API: { type: "function", name, description, parameters } + * + * Uses base provider's convertToolSchemaForOpenAI() for schema hardening + * (additionalProperties: false, ensureAllRequired) and handles MCP tools. + */ private mapResponseTools(tools?: any[]): any[] | undefined { const converted = this.convertToolsForOpenAI(tools) if (!converted?.length) { @@ -200,7 +200,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler let stream: AsyncIterable try { stream = (await this.client.responses.create( - { ...requestBody, stream: true } as any, + requestBody, abortSignal ? { signal: abortSignal } : undefined, )) as unknown as AsyncIterable } catch (error) { diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index 08c066f21a..249b18d1d9 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -137,7 +137,7 @@ export function getModelParams({ // Here we simply propagate the resolved effort into the params, while // still treating "disable" as an omission. if (effort && effort !== "disable") { - reasoningEffort = effort as ReasoningEffortExtended + reasoningEffort = effort } } diff --git a/src/api/transform/openai-format.ts b/src/api/transform/openai-format.ts index d0f0df172b..50a4a6d015 100644 --- a/src/api/transform/openai-format.ts +++ b/src/api/transform/openai-format.ts @@ -465,7 +465,7 @@ export function convertToOpenAiMessages( ] as OpenAI.Chat.ChatCompletionToolMessageParam if (lastToolMessage?.role === "tool") { const additionalText = filteredNonToolMessages - .map((part) => sanitizeSurrogates((part as Anthropic.TextBlockParam).text)) + .map((part) => sanitizeSurrogates(part.text)) .join("\n") lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` } diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index 3d3c68c03a..dc1d634156 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -63,7 +63,7 @@ export const getOpenRouterReasoning = ({ ? { max_tokens: reasoningBudget } : shouldUseReasoningEffort({ model, settings }) ? reasoningEffort && reasoningEffort !== "disable" - ? { effort: reasoningEffort as ReasoningEffortExtended } + ? { effort: reasoningEffort } : undefined : undefined @@ -185,8 +185,7 @@ export const getGeminiReasoning = ({ // We intentionally ignore enableReasoningEffort here so that explicitly chosen // efforts in the UI (e.g. "High" for gemini-3-pro-preview) always translate // into a thinkingConfig, regardless of legacy boolean flags. - const selectedEffort = (settings.reasoningEffort ?? model.reasoningEffort) as - ReasoningEffortExtended | "disable" | undefined + const selectedEffort = settings.reasoningEffort ?? model.reasoningEffort // Respect "off" / unset semantics from the effort selector itself. if (!selectedEffort || selectedEffort === "disable") { diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 51073af4c4..32263840bf 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": 5 + "count": 4 } }, "api/transform/__tests__/ai-sdk.spec.ts": { diff --git a/src/eslint.config.mjs b/src/eslint.config.mjs index a5286ca0e6..f3a7bd56ba 100644 --- a/src/eslint.config.mjs +++ b/src/eslint.config.mjs @@ -80,6 +80,21 @@ export default [ "@typescript-eslint/no-floating-promises": "error", }, }, + { + // Ratchet: flag type assertions (`as X`, `!`) that do not change the type. + // They hide type errors once an SDK type narrows. Tests are not covered yet. + files: ["api/**/*.ts"], + ignores: ["api/**/__tests__/**"], + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + "@typescript-eslint/no-unnecessary-type-assertion": "error", + }, + }, { ignores: [".stryker-tmp", "webview-ui", "out"], },