diff --git a/src/api/providers/__tests__/anthropic-vertex.spec.ts b/src/api/providers/__tests__/anthropic-vertex.spec.ts index 3e98f3ec5b..ba983f1fcd 100644 --- a/src/api/providers/__tests__/anthropic-vertex.spec.ts +++ b/src/api/providers/__tests__/anthropic-vertex.spec.ts @@ -8,6 +8,7 @@ import { VERTEX_1M_CONTEXT_MODEL_IDS } from "@roo-code/types" import { AnthropicVertexHandler } from "../anthropic-vertex" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { makeCreateMessageMetadata } from "../../../test-utils/api" vitest.mock("../utils/timeout-config", () => ({ getApiRequestTimeout: vitest.fn().mockReturnValue(300_000), @@ -746,6 +747,96 @@ describe("VertexHandler", () => { expect(calledMessages).toHaveLength(2) // Only the two user messages expect(calledMessages.every((m: any) => m.role === "user")).toBe(true) }) + + it("should reject with AbortError when createMessage is called with an already-aborted signal", async () => { + const abortedController = new AbortController() + abortedController.abort() + + const mockCreate = vitest + .spyOn(handler["client"].messages, "create") + .mockImplementation((_params: unknown, options?: { signal?: AbortSignal | null }) => { + if (options?.signal?.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + throw error + } + return asyncStreamFrom([]) as never + }) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: "Hello" }], + makeCreateMessageMetadata({ abortSignal: abortedController.signal }), + ) + + await expect(stream.next()).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("should abort the request when the external signal aborts mid-flight", async () => { + const controller = new AbortController() + + const mockCreate = vitest.spyOn(handler["client"].messages, "create").mockImplementation( + (_params: unknown, options?: { signal?: AbortSignal | null }) => + new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + return + } + if (signal.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + return + } + signal.addEventListener( + "abort", + () => { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + }, + { once: true }, + ) + }) as never, + ) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: "Hello" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const promise = stream.next() + controller.abort() + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("should remove the external abort listener when the stream completes", async () => { + const handlerWithSignal = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const controller = new AbortController() + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + + const stream = handlerWithSignal.createMessage( + systemPrompt, + [{ role: "user", content: "Hello" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + await collectStream(stream) + + expect(addEventListenerSpy).toHaveBeenCalledTimes(1) + const [event, listener] = addEventListenerSpy.mock.calls[0] + expect(event).toBe("abort") + // The same retained callback must be detached once the stream is done. + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1) + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", listener) + }) }) describe("completePrompt", () => { @@ -758,18 +849,22 @@ describe("VertexHandler", () => { const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(handler["client"].messages.create).toHaveBeenCalledWith({ - model: "claude-3-5-sonnet-v2@20241022", - max_tokens: 8192, - temperature: 0, - messages: [ - { - role: "user", - content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }], - }, - ], - stream: false, - }) + expect(handler["client"].messages.create).toHaveBeenCalledWith( + { + model: "claude-3-5-sonnet-v2@20241022", + max_tokens: 8192, + temperature: 0, + messages: [ + { + role: "user", + content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }], + }, + ], + stream: false, + thinking: undefined, + }, + undefined, + ) }) it("should handle API errors for Claude", async () => { @@ -820,6 +915,98 @@ describe("VertexHandler", () => { expect(result).toBe("") }) + it("should pass abort signal through to client", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const controller = new AbortController() + const mockCreate = vitest + .spyOn(handler["client"].messages, "create") + .mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never) + + await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + + const [, requestOptions] = mockCreate.mock.calls[0] + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.any(Object), + ) + expect(requestOptions?.signal).toBe(controller.signal) + }) + + it("should work without options (backward compatible)", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const mockCreate = vitest + .spyOn(handler["client"].messages, "create") + .mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never) + + const result = await handler.completePrompt("test prompt") + expect(result).toBe("response") + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), undefined) + }) + + it("completePrompt should pass signal through to client", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const controller = new AbortController() + const mockCreate = vitest + .spyOn(handler["client"].messages, "create") + .mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never) + + await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + + const [, requestOptions] = mockCreate.mock.calls[0] + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ timeout: 5000 }), + ) + expect(requestOptions?.signal).toBe(controller.signal) + }) + + it("completePrompt should pass timeoutMs when provided", async () => { + const mockCreate = vitest + .spyOn(handler["client"].messages, "create") + .mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never) + + await handler.completePrompt("test prompt", { timeoutMs: 3000 }) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ timeout: 3000 }), + ) + }) + + it("completePrompt should pass timeout when timeoutMs=0 (defined check)", async () => { + handler = new AnthropicVertexHandler({ + apiModelId: "claude-3-5-sonnet-v2@20241022", + vertexProjectId: "test-project", + vertexRegion: "us-central1", + }) + + const mockCreate = vitest + .spyOn(handler["client"].messages, "create") + .mockResolvedValue({ content: [{ type: "text", text: "response" }] } as never) + + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + // 0 is a defined value: it must reach the client as `timeout: 0`, + // not be dropped by a truthiness check. + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + expect.objectContaining({ timeout: 0 }), + ) + }) + it("should handle empty content array for Claude", async () => { handler = new AnthropicVertexHandler({ apiModelId: "claude-3-5-sonnet-v2@20241022", diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index 21d2816ec7..de23a5205a 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -3,6 +3,7 @@ import { AnthropicHandler } from "../anthropic" import { ApiHandlerOptions } from "../../../shared/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { makeCreateMessageMetadata } from "../../../test-utils/api" import { clearAllMocks } from "../../../test-utils/reset" // Mock TelemetryService @@ -476,20 +477,83 @@ describe("AnthropicHandler", () => { expect(requestBody?.model).toBe("claude-sonnet-5-bf") expect(requestBody?.thinking).toEqual({ type: "adaptive" }) }) + + it("should reject with AbortError when createMessage is called with an already-aborted signal", async () => { + const abortedController = new AbortController() + abortedController.abort() + + mockCreate.mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + if (options?.signal?.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + throw error + } + return asyncStreamFrom([]) + }) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: "Hello" }], + makeCreateMessageMetadata({ abortSignal: abortedController.signal }), + ) + + await expect(stream.next()).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("should abort the request when the external signal aborts mid-flight", async () => { + const controller = new AbortController() + + mockCreate.mockImplementation((_params: unknown, options?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + return + } + if (signal.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + return + } + signal.addEventListener( + "abort", + () => { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + }, + { once: true }, + ) + }) + }) + + const stream = handler.createMessage( + systemPrompt, + [{ role: "user", content: "Hello" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const promise = stream.next() + controller.abort() + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + }) }) describe("completePrompt", () => { it("should complete prompt successfully", async () => { const result = await handler.completePrompt("Test prompt") expect(result).toBe("Test response") - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.apiModelId, - messages: [{ role: "user", content: "Test prompt" }], - max_tokens: 8192, - temperature: 0, - thinking: undefined, - stream: false, - }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: mockOptions.apiModelId, + messages: [{ role: "user", content: "Test prompt" }], + max_tokens: 8192, + temperature: 0, + thinking: undefined, + stream: false, + }, + undefined, + ) }) it("should handle API errors", async () => { @@ -512,6 +576,95 @@ describe("AnthropicHandler", () => { const result = await handler.completePrompt("Test prompt") expect(result).toBe("") }) + + it("should pass abort signal through to client", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: mockOptions.apiModelId, + messages: [{ role: "user", content: "test prompt" }], + max_tokens: 8192, + temperature: 0, + thinking: undefined, + stream: false, + }, + { signal: controller.signal }, + ) + }) + + it("should work without options (backward compatible)", async () => { + mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] }) + const result = await handler.completePrompt("test prompt") + expect(result).toBe("response") + expect(mockCreate).toHaveBeenCalledWith( + { + model: mockOptions.apiModelId, + messages: [{ role: "user", content: "test prompt" }], + max_tokens: 8192, + temperature: 0, + thinking: undefined, + stream: false, + }, + undefined, + ) + }) + + it("should merge signal and timeout together", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 10000 }) + expect(mockCreate).toHaveBeenCalledWith( + { + model: mockOptions.apiModelId, + messages: [{ role: "user", content: "test prompt" }], + max_tokens: 8192, + temperature: 0, + thinking: undefined, + stream: false, + }, + expect.objectContaining({ signal: controller.signal, timeout: 10000 }), + ) + }) + + it("should pass timeoutMs through to client alongside abortSignal", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: mockOptions.apiModelId }), + expect.objectContaining({ signal: controller.signal, timeout: 5000 }), + ) + }) + + it("should pass the same signal instance", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + expect(mockCreate).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ signal: controller.signal }), + ) + // Verify it's the exact same instance, not just equal + const callOptions = mockCreate.mock.calls[0][1] + expect(callOptions?.signal).toBe(controller.signal) + }) + + it("should not include signal-related options when not provided", async () => { + mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] }) + await handler.completePrompt("test prompt") + expect(mockCreate).toHaveBeenCalledWith(expect.any(Object), undefined) + }) + + it("should pass timeout when timeoutMs=0 (defined check)", async () => { + mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] }) + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + // timeoutMs=0 must be forwarded as an explicit 0 timeout, not dropped as if unset + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: mockOptions.apiModelId }), { + timeout: 0, + }) + }) }) describe("getModel", () => { diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 01102b0457..08ac58591c 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -14,6 +14,7 @@ import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo- import { MiniMaxHandler } from "../minimax" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { makeCreateMessageMetadata } from "../../../test-utils/api" import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("@anthropic-ai/sdk", () => { @@ -239,6 +240,63 @@ describe("MiniMaxHandler", () => { await expect(handler.completePrompt("test prompt")).rejects.toThrow() }) + it("should pass abort signal through to client", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "response" }], + }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal }) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + { signal: controller.signal }, // second arg (options) + ) + }) + + it("should work without options (backward compatible)", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "response" }], + }) + const result = await handler.completePrompt("test prompt") + expect(result).toBe("response") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + undefined, // second arg (options) + ) + }) + + it("should pass timeout through to client", async () => { + const controller = new AbortController() + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "response" }], + }) + await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { + signal: controller.signal, + timeout: 5000, + }) + }) + + it("should pass only timeoutMs when no signal provided", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "response" }], + }) + await handler.completePrompt("test prompt", { timeoutMs: 3000 }) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { + timeout: 3000, + }) + }) + + it("should pass timeout when timeoutMs=0 (defined check)", async () => { + mockCreate.mockResolvedValueOnce({ + content: [{ type: "text", text: "response" }], + }) + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + { timeout: 0 }, // !== undefined check means 0 is passed through + ) + }) + it("createMessage should yield text content from stream", async () => { const testContent = "This is test content from MiniMax stream" @@ -306,6 +364,7 @@ describe("MiniMaxHandler", () => { messages: expect.any(Array), stream: true, }), + undefined, ) }) @@ -319,6 +378,7 @@ describe("MiniMaxHandler", () => { expect.objectContaining({ temperature: 1, }), + undefined, ) }) @@ -375,6 +435,66 @@ describe("MiniMaxHandler", () => { arguments: undefined, }) }) + + it("should reject with AbortError when createMessage is called with an already-aborted signal", async () => { + const abortedController = new AbortController() + abortedController.abort() + + mockCreate.mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + if (options?.signal?.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + throw error + } + return asyncStreamFrom([]) + }) + + const stream = handler.createMessage( + "system prompt", + [{ role: "user", content: "Hello" }], + makeCreateMessageMetadata({ abortSignal: abortedController.signal }), + ) + + await expect(stream.next()).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("should abort the request when the external signal aborts mid-flight", async () => { + const controller = new AbortController() + + mockCreate.mockImplementation((_params: unknown, options?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + return + } + if (signal.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + return + } + signal.addEventListener( + "abort", + () => { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + }, + { once: true }, + ) + }) + }) + + const stream = handler.createMessage( + "system prompt", + [{ role: "user", content: "Hello" }], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const promise = stream.next() + controller.abort() + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + }) }) describe("Model Configuration", () => { diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index a0427b6fe0..3365886ae1 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -14,16 +14,23 @@ const mockResponsesCreate = vitest.hoisted(() => vitest.fn()) vitest.mock("openai", async () => { const { mockOpenAiResponsesClient } = await import("../../../test-utils/api") - return mockOpenAiResponsesClient(mockResponsesCreate) + const actual = await vi.importActual("openai") + return { + ...mockOpenAiResponsesClient(mockResponsesCreate), + // Expose the real SDK error class so the mock transport can throw it exactly + // the way the OpenAI SDK does when a request signal aborts. + APIUserAbortError: actual.APIUserAbortError, + } }) -import OpenAI from "openai" +import OpenAI, { APIUserAbortError } from "openai" import type { Anthropic } from "@anthropic-ai/sdk" import { xaiDefaultModelId, xaiModels } from "@roo-code/types" import { XAIHandler } from "../xai" -import { asyncStreamFrom } from "../../../test-utils/stream" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { makeCreateMessageMetadata } from "../../../test-utils/api" import { clearAllMocks } from "../../../test-utils/reset" describe("XAIHandler", () => { @@ -83,6 +90,7 @@ describe("XAIHandler", () => { store: false, include: ["reasoning.encrypted_content"], }), + undefined, ) }) @@ -212,6 +220,81 @@ describe("XAIHandler", () => { tool_choice: "auto", parallel_tool_calls: true, }), + undefined, + ) + }) + + 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" }, + }), + undefined, + ) + }) + + 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" }, + ], + }, + }), + undefined, ) }) @@ -232,6 +315,69 @@ describe("XAIHandler", () => { await expect(handler.completePrompt("test prompt")).rejects.toThrow(`xAI completion error: ${errorMessage}`) }) + it("completePrompt should surface the SDK APIUserAbortError unmodified on abort", async () => { + const controller = new AbortController() + controller.abort() + const sdkAbortError = new APIUserAbortError() + mockResponsesCreate.mockRejectedValueOnce(sdkAbortError) + + // The error must surface as the same SDK instance, not wrapped by handleOpenAIError + await expect(handler.completePrompt("test prompt", { abortSignal: controller.signal })).rejects.toBe( + sdkAbortError, + ) + }) + + it("completePrompt should pass abort signal through to client", async () => { + const controller = new AbortController() + 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, + }) + }) + + it("completePrompt should work without options (backward compatible)", async () => { + mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) + + const result = await handler.completePrompt("test prompt") + expect(result).toBe("response") + expect(mockResponsesCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + undefined, + ) + }) + + it("completePrompt should pass timeout through to client", async () => { + const controller = new AbortController() + 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, + }) + }) + + it("completePrompt should pass only timeoutMs when no signal provided", async () => { + mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) + + await handler.completePrompt("test prompt", { timeoutMs: 3000 }) + expect(mockResponsesCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), { + timeout: 3000, + }) + }) + + it("completePrompt should pass timeout when timeoutMs=0 (defined check)", async () => { + mockResponsesCreate.mockResolvedValueOnce({ output_text: "response" }) + + await handler.completePrompt("test prompt", { timeoutMs: 0 }) + expect(mockResponsesCreate).toHaveBeenCalledWith( + expect.objectContaining({ model: expect.any(String) }), + { timeout: 0 }, // !== undefined check means 0 is passed through + ) + }) + it("should include reasoning effort for mini models in Responses API format", async () => { const miniModelHandler = new XAIHandler({ apiModelId: "grok-3-mini", @@ -249,6 +395,7 @@ describe("XAIHandler", () => { effort: "high", }), }), + undefined, ) }) @@ -269,6 +416,7 @@ describe("XAIHandler", () => { effort: "high", }), }), + undefined, ) }) @@ -290,6 +438,7 @@ describe("XAIHandler", () => { effort: "low", }), }), + undefined, ) }) @@ -315,4 +464,114 @@ describe("XAIHandler", () => { const stream = handler.createMessage("test prompt", []) await expect(stream.next()).rejects.toThrow(`xAI completion error: ${errorMessage}`) }) + + it("createMessage should surface the SDK APIUserAbortError unmodified on abort", async () => { + const abortedController = new AbortController() + abortedController.abort() + const sdkAbortError = new APIUserAbortError() + + mockResponsesCreate.mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + // Mimic the OpenAI SDK: reject with its own APIUserAbortError when the + // request signal is aborted. + if (options?.signal?.aborted) { + throw sdkAbortError + } + return asyncStreamFrom([]) + }) + + const stream = handler.createMessage( + "test prompt", + [], + makeCreateMessageMetadata({ abortSignal: abortedController.signal }), + ) + + // The SDK error must surface as the same instance, not wrapped by handleOpenAIError + await expect(stream.next()).rejects.toBe(sdkAbortError) + }) + + it("should reject with AbortError when createMessage is called with an already-aborted signal", async () => { + const abortedController = new AbortController() + abortedController.abort() + + mockResponsesCreate.mockImplementation(async (_params: unknown, options?: { signal?: AbortSignal }) => { + if (options?.signal?.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + throw error + } + return asyncStreamFrom([]) + }) + + const stream = handler.createMessage( + "test prompt", + [], + makeCreateMessageMetadata({ abortSignal: abortedController.signal }), + ) + + await expect(stream.next()).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("should abort the request when the external signal aborts mid-flight", async () => { + const controller = new AbortController() + + mockResponsesCreate.mockImplementation((_params: unknown, options?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + const signal = options?.signal + if (!signal) { + return + } + if (signal.aborted) { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + return + } + signal.addEventListener( + "abort", + () => { + const error = new Error("The operation was aborted") + error.name = "AbortError" + reject(error) + }, + { once: true }, + ) + }) + }) + + const stream = handler.createMessage( + "test prompt", + [], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const promise = stream.next() + controller.abort() + await expect(promise).rejects.toMatchObject({ name: "AbortError" }) + }) + + it("should remove the external abort listener when the stream completes", async () => { + const controller = new AbortController() + const addEventListenerSpy = vitest.spyOn(controller.signal, "addEventListener") + const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener") + + mockResponsesCreate.mockResolvedValueOnce( + asyncStreamFrom([{ type: "response.output_text.delta", delta: "done" }]), + ) + + const stream = handler.createMessage( + "test prompt", + [], + makeCreateMessageMetadata({ abortSignal: controller.signal }), + ) + + const chunks = await collectStream(stream) + expect(chunks).toEqual([{ type: "text", text: "done" }]) + + expect(addEventListenerSpy).toHaveBeenCalledTimes(1) + const [event, listener] = addEventListenerSpy.mock.calls[0] + expect(event).toBe("abort") + // The same retained callback must be detached once the stream is done. + expect(removeEventListenerSpy).toHaveBeenCalledTimes(1) + expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", listener) + }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 7b72b1100b..3f36720055 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -86,6 +86,29 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple tool_choice: convertOpenAIToolChoiceToAnthropic(metadata?.tool_choice, 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 + let abortSignal: AbortSignal | undefined + let removeExternalAbortListener: (() => void) | undefined + if (externalAbortSignal) { + const controller = new AbortController() + if (externalAbortSignal.aborted) { + controller.abort() + } else { + // Retain the listener so it can be removed again once streaming + // finishes; otherwise a long-lived external signal would keep one + // listener (and its closed-over controller) per completed request. + const onExternalAbort = () => controller.abort() + externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) + removeExternalAbortListener = () => externalAbortSignal.removeEventListener("abort", onExternalAbort) + } + abortSignal = controller.signal + } + /** * Vertex API has specific limitations for prompt caching: * 1. Maximum of 4 blocks can have cache_control @@ -114,100 +137,115 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple } as Anthropic.Messages.MessageCreateParamsStreaming // and prompt caching - const requestOptions = betas?.length ? { headers: { "anthropic-beta": betas.join(",") } } : undefined - - const stream = await this.client.messages.create(params, requestOptions) + const requestOptions: Anthropic.RequestOptions = {} + if (betas?.length) { + requestOptions.headers = { "anthropic-beta": betas.join(",") } + } + if (abortSignal) { + requestOptions.signal = abortSignal + } - for await (const chunk of stream) { - switch (chunk.type) { - case "message_start": { - const usage = chunk.message!.usage + try { + const stream = await this.client.messages.create( + params, + Object.keys(requestOptions).length > 0 ? requestOptions : undefined, + ) + + for await (const chunk of stream) { + switch (chunk.type) { + case "message_start": { + const usage = chunk.message!.usage + + yield { + type: "usage", + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheWriteTokens: usage.cache_creation_input_tokens || undefined, + cacheReadTokens: usage.cache_read_input_tokens || undefined, + } - yield { - type: "usage", - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheWriteTokens: usage.cache_creation_input_tokens || undefined, - cacheReadTokens: usage.cache_read_input_tokens || undefined, + break } + case "message_delta": { + yield { + type: "usage", + inputTokens: 0, + outputTokens: chunk.usage!.output_tokens || 0, + } - break - } - case "message_delta": { - yield { - type: "usage", - inputTokens: 0, - outputTokens: chunk.usage!.output_tokens || 0, + break } - - break - } - case "content_block_start": { - switch (chunk.content_block!.type) { - case "text": { - if (chunk.index! > 0) { - yield { type: "text", text: "\n" } + case "content_block_start": { + switch (chunk.content_block!.type) { + case "text": { + if (chunk.index! > 0) { + yield { type: "text", text: "\n" } + } + + yield { type: "text", text: chunk.content_block!.text } + break } + case "thinking": { + if (chunk.index! > 0) { + yield { type: "reasoning", text: "\n" } + } - yield { type: "text", text: chunk.content_block!.text } - break - } - case "thinking": { - if (chunk.index! > 0) { - yield { type: "reasoning", text: "\n" } + yield { type: "reasoning", text: (chunk.content_block as any).thinking } + break } - - yield { type: "reasoning", text: (chunk.content_block as any).thinking } - break - } - case "tool_use": { - // Emit initial tool call partial with id and name - yield { - type: "tool_call_partial", - index: chunk.index, - id: chunk.content_block!.id, - name: chunk.content_block!.name, - arguments: undefined, + case "tool_use": { + // Emit initial tool call partial with id and name + yield { + type: "tool_call_partial", + index: chunk.index, + id: chunk.content_block!.id, + name: chunk.content_block!.name, + arguments: undefined, + } + break } - break } - } - break - } - case "content_block_delta": { - switch (chunk.delta!.type) { - case "text_delta": { - yield { type: "text", text: chunk.delta!.text } - break - } - case "thinking_delta": { - yield { type: "reasoning", text: (chunk.delta as any).thinking } - break - } - case "input_json_delta": { - // Emit tool call partial chunks as arguments stream in - yield { - type: "tool_call_partial", - index: chunk.index, - id: undefined, - name: undefined, - arguments: (chunk.delta as any).partial_json, + break + } + case "content_block_delta": { + switch (chunk.delta!.type) { + case "text_delta": { + yield { type: "text", text: chunk.delta!.text } + break + } + case "thinking_delta": { + yield { type: "reasoning", text: (chunk.delta as any).thinking } + break + } + case "input_json_delta": { + // Emit tool call partial chunks as arguments stream in + yield { + type: "tool_call_partial", + index: chunk.index, + id: undefined, + name: undefined, + arguments: (chunk.delta as any).partial_json, + } + break } - break } - } - 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 + 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 + } } } + } finally { + // Release the listener once the stream is consumed, whether the + // request completed, failed, or the generator was closed early. + removeExternalAbortListener?.() } } @@ -297,7 +335,19 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple stream: false, } as Anthropic.Messages.MessageCreateParamsNonStreaming - const response = await this.client.messages.create(params) + // Build request options with abortSignal and/or timeout handling + const requestOptions: Anthropic.RequestOptions = {} + if (options?.abortSignal) { + requestOptions.signal = options.abortSignal + } + if (options?.timeoutMs !== undefined) { + requestOptions.timeout = options.timeoutMs + } + + const response = await this.client.messages.create( + params, + Object.keys(requestOptions).length > 0 ? requestOptions : undefined, + ) const content = response.content.find(({ type }) => type === "text") return content?.type === "text" ? content.text : "" diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index b55c8b3089..1253e3b55a 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -98,6 +98,23 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa tool_choice: convertOpenAIToolChoiceToAnthropic(metadata?.tool_choice, 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 + let abortSignal: AbortSignal | undefined + if (externalAbortSignal) { + const controller = new AbortController() + if (externalAbortSignal.aborted) { + controller.abort() + } else { + externalAbortSignal.addEventListener("abort", () => controller.abort(), { once: true }) + } + abortSignal = controller.signal + } + switch (modelId) { case "claude-sonnet-5": case "claude-sonnet-4-6": @@ -190,9 +207,12 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa case "claude-haiku-4-5-20251001": case "claude-3-haiku-20240307": betas.push("prompt-caching-2024-07-31") - return { headers: { "anthropic-beta": betas.join(",") } } + return { + headers: { "anthropic-beta": betas.join(",") }, + ...(abortSignal && { signal: abortSignal }), + } default: - return undefined + return abortSignal ? { signal: abortSignal } : undefined } })(), ) @@ -223,6 +243,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } stream = (await this.client.messages.create( requestParams as Anthropic.Messages.MessageCreateParamsStreaming, + abortSignal ? { signal: abortSignal } : undefined, )) as any } catch (error) { TelemetryService.instance.captureException( @@ -436,14 +457,26 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa let message try { - message = await this.client.messages.create({ - model, - max_tokens: ANTHROPIC_DEFAULT_MAX_TOKENS, - thinking: undefined, - temperature, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + // Build request options with both abortSignal and timeout handling + const requestOptions: Anthropic.RequestOptions = {} + if (options?.abortSignal) { + requestOptions.signal = options.abortSignal + } + if (options?.timeoutMs !== undefined) { + requestOptions.timeout = options.timeoutMs + } + + message = await this.client.messages.create( + { + model, + max_tokens: ANTHROPIC_DEFAULT_MAX_TOKENS, + thinking: undefined, + temperature, + messages: [{ role: "user", content: prompt }], + stream: false, + }, + Object.keys(requestOptions).length > 0 ? requestOptions : undefined, + ) } catch (error) { TelemetryService.instance.captureException( new ApiProviderError( diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index e209add72d..9e9c219a25 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -85,6 +85,23 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand const cacheControl: CacheControlEphemeral = { type: "ephemeral" } const { id: modelId, info, maxTokens, temperature } = this.getModel() + // 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 + let abortSignal: AbortSignal | undefined + if (externalAbortSignal) { + const controller = new AbortController() + if (externalAbortSignal.aborted) { + controller.abort() + } else { + externalAbortSignal.addEventListener("abort", () => controller.abort(), { once: true }) + } + abortSignal = controller.signal + } + // MiniMax M2 models support prompt caching const supportsPromptCache = info.supportsPromptCache ?? false @@ -113,7 +130,10 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand tool_choice: convertOpenAIToolChoice(metadata?.tool_choice), } - const stream = await this.client.messages.create(requestParams) + const stream = await this.client.messages.create( + requestParams, + abortSignal ? { signal: abortSignal } : undefined, + ) let inputTokens = 0 let outputTokens = 0 @@ -292,13 +312,25 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand async completePrompt(prompt: string, options?: CompletePromptOptions) { const { id: model, temperature } = this.getModel() - const message = await this.client.messages.create({ - model, - max_tokens: 16_384, - temperature: temperature ?? 1.0, - messages: [{ role: "user", content: prompt }], - stream: false, - }) + // Build request options with abortSignal and/or timeout handling + const requestOptions: Anthropic.RequestOptions = {} + if (options?.abortSignal) { + requestOptions.signal = options.abortSignal + } + if (options?.timeoutMs !== undefined) { + requestOptions.timeout = options.timeoutMs + } + + const message = await this.client.messages.create( + { + model, + max_tokens: 16_384, + temperature: temperature ?? 1.0, + messages: [{ role: "user", content: prompt }], + stream: false, + }, + Object.keys(requestOptions).length > 0 ? requestOptions : undefined, + ) const content = message.content.find(({ type }) => type === "text") return content?.type === "text" ? content.text : "" diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 189ec4e9ed..edbc89a10c 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -1,5 +1,5 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import OpenAI, { APIUserAbortError } from "openai" import { type XAIModelId, xaiDefaultModelId, xaiModels, ApiProviderError } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -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) { @@ -95,68 +140,129 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler const input = convertToResponsesApiInput(messages) const responseTools = this.mapResponseTools(metadata?.tools) - // Build request options - const requestBody: Record = { - model: model.id, - instructions: systemPrompt, - input: input, - stream: true, - store: false, // Don't store responses server-side for privacy - include: ["reasoning.encrypted_content"], + // 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 + let abortSignal: AbortSignal | undefined + let removeExternalAbortListener: (() => void) | undefined + if (externalAbortSignal) { + const controller = new AbortController() + if (externalAbortSignal.aborted) { + controller.abort() + } else { + // Retain the listener so it can be removed again once streaming + // finishes; otherwise a long-lived external signal would keep one + // listener (and its closed-over controller) per completed request. + const onExternalAbort = () => controller.abort() + externalAbortSignal.addEventListener("abort", onExternalAbort, { once: true }) + removeExternalAbortListener = () => externalAbortSignal.removeEventListener("abort", onExternalAbort) + } + abortSignal = controller.signal } - if (model.maxTokens) { - requestBody.max_output_tokens = model.maxTokens - } + try { + // Build request options + const requestBody: OpenAI.Responses.ResponseCreateParamsStreaming = { + model: model.id, + instructions: systemPrompt, + input: input, + stream: true, + store: false, // Don't store responses server-side for privacy + include: ["reasoning.encrypted_content"], + } - if (model.temperature !== undefined) { - requestBody.temperature = model.temperature - } + if (model.maxTokens) { + requestBody.max_output_tokens = model.maxTokens + } - 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 - } + if (model.temperature !== undefined) { + requestBody.temperature = model.temperature + } - // Pass reasoning effort for models that support it (e.g., grok-4.5, grok-3-mini). - // The xAI Responses API uses `reasoning: { effort }` format (not `reasoning_effort` - // which is the Chat Completions format), so we convert from the OpenAI params shape. - if (model.reasoning) { - requestBody.reasoning = { effort: model.reasoning.reasoning_effort } - } + if (responseTools) { + requestBody.tools = responseTools + // 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(metadata?.tool_choice ?? "auto") + requestBody.parallel_tool_calls = metadata?.parallelToolCalls ?? true + } - let stream: AsyncIterable - try { - stream = (await this.client.responses.create({ - ...requestBody, - stream: true, - } as any)) as unknown as AsyncIterable - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") - TelemetryService.instance.captureException(apiError) - throw handleOpenAIError(error, this.providerName) - } + // Pass reasoning effort for models that support it (e.g., grok-4.5, grok-3-mini). + // The xAI Responses API uses `reasoning: { effort }` format (not `reasoning_effort` + // which is the Chat Completions format), so we convert from the OpenAI params shape. + if (model.reasoning) { + requestBody.reasoning = { effort: model.reasoning.reasoning_effort } + } + + let stream: AsyncIterable + try { + stream = await this.client.responses.create( + { + ...requestBody, + stream: true, + }, + abortSignal ? { signal: abortSignal } : undefined, + ) + } catch (error) { + // Let abort errors propagate unmodified so callers can recognize them: + // native AbortError (error.name === "AbortError") and the OpenAI SDK's + // APIUserAbortError, which the SDK throws when the request signal aborts + // (the SDK class does not set a distinctive error.name in v5, so use + // instanceof). + if ((error instanceof Error && error.name === "AbortError") || error instanceof APIUserAbortError) { + throw error + } + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") + TelemetryService.instance.captureException(apiError) + throw handleOpenAIError(error, this.providerName) + } - const normalizeUsage = createUsageNormalizer() - yield* processResponsesApiStream(stream, normalizeUsage) + const normalizeUsage = createUsageNormalizer() + yield* processResponsesApiStream(stream, normalizeUsage) + } finally { + // Release the listener once the stream is consumed, whether the + // request completed, failed, or the generator was closed early. + removeExternalAbortListener?.() + } } async completePrompt(prompt: string, options?: CompletePromptOptions): Promise { const model = this.getModel() try { - const response = await this.client.responses.create({ - model: model.id, - input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], - store: false, - }) + // Build request options with abortSignal and/or timeout handling + const requestOptions: OpenAI.RequestOptions = {} + if (options?.abortSignal) { + requestOptions.signal = options.abortSignal + } + if (options?.timeoutMs !== undefined) { + requestOptions.timeout = options.timeoutMs + } + + const response = await this.client.responses.create( + { + model: model.id, + input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }], + store: false, + }, + Object.keys(requestOptions).length > 0 ? requestOptions : undefined, + ) // output_text is a convenience field on the Responses API response return response.output_text || "" } catch (error) { + // Let abort errors propagate unmodified so callers can recognize them: + // native AbortError (error.name === "AbortError") and the OpenAI SDK's + // APIUserAbortError, which the SDK throws when the request signal aborts + // (the SDK class does not set a distinctive error.name in v5, so use + // instanceof). + if ((error instanceof Error && error.name === "AbortError") || error instanceof APIUserAbortError) { + throw error + } const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "completePrompt") TelemetryService.instance.captureException(apiError) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 0706dbe6fb..4ccae43858 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": {