Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
// npx vitest run api/providers/__tests__/base-openai-compatible-provider.spec.ts

import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import OpenAI, { APIUserAbortError } from "openai"

import type { ModelInfo } from "@roo-code/types"

import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
import { clearAllMocks } from "../../../test-utils/reset"
import { captureError } from "../../../test-utils/errors"

// Create mock functions
const mockCreate = vi.fn()

// Mock OpenAI module
vi.mock("openai", () => ({
// Named export consumed by the provider for abort-error normalization
APIUserAbortError: class extends Error {},
default: vi.fn(function () {
return {
chat: {
Expand Down Expand Up @@ -278,6 +281,233 @@ describe("BaseOpenAiCompatibleProvider", () => {
})
})

describe("abort signal wiring", () => {
it("should pass the metadata abort signal to the client request", async () => {
const controller = new AbortController()
mockCreate.mockImplementationOnce(() => asyncStreamFrom([]))

const stream = handler.createMessage("system prompt", [], {
taskId: "test-task",
abortSignal: controller.signal,
})
await stream.next()

expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), {
signal: controller.signal,
})
})

it("should reject before issuing any request when the abort signal is already aborted", async () => {
const controller = new AbortController()
controller.abort()

await expect(async () => {
for await (const _ of handler.createMessage("system prompt", [], {
taskId: "test-task",
abortSignal: controller.signal,
})) {
// consume
}
}).rejects.toMatchObject({ name: "AbortError", message: "This operation was aborted" })
expect(mockCreate).not.toHaveBeenCalled()
})

it("should normalize the SDK APIUserAbortError from the stream path into the abort contract", async () => {
mockCreate.mockImplementationOnce(() => {
throw new APIUserAbortError()
})

const result = await captureError(
(async () => {
for await (const _ of handler.createMessage("system prompt", [])) {
// consume
}
})(),
)

// The SDK error has name "Error" and a message ending in a period; the
// provider must rethrow the Task.ts contract shape instead.
expect(result.name).toBe("AbortError")
expect(result.message).toBe("TestProvider request aborted")
expect(result.message.endsWith("aborted")).toBe(true)
})

it("should normalize an abort error raised during stream iteration into the abort contract", async () => {
mockCreate.mockImplementationOnce(() =>
(async function* () {
yield { choices: [{ delta: { content: "partial" } }] }
throw new APIUserAbortError()
})(),
)

const result = await captureError(
(async () => {
for await (const _ of handler.createMessage("system prompt", [])) {
// consume
}
})(),
)

// The iterator rejects after the first chunk; the provider must normalize
// it to the Task.ts contract shape (name + message ending in "aborted").
expect(result.name).toBe("AbortError")
expect(result.message).toBe("TestProvider request aborted")
expect(result.message.endsWith("aborted")).toBe(true)
})

it("should wrap a non-abort base_resp stream error with the provider prefix through the iteration wrapper", async () => {
mockCreate.mockImplementationOnce(() =>
asyncStreamFrom([
{
choices: [{ delta: { content: "partial" } }],
base_resp: { status_code: 1041, status_msg: "Invalid token" },
},
]),
)

const result = await captureError(
(async () => {
for await (const _ of handler.createMessage("system prompt", [])) {
// consume
}
})(),
)

expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1041): Invalid token")
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it("should fall back to an Unknown error when a base_resp stream chunk has no status_msg", async () => {
mockCreate.mockImplementationOnce(() =>
asyncStreamFrom([
{
choices: [{ delta: { content: "partial" } }],
base_resp: { status_code: 1041 },
},
]),
)

const result = await captureError(
(async () => {
for await (const _ of handler.createMessage("system prompt", [])) {
// consume
}
})(),
)

expect(result.message).toBe("TestProvider completion error: TestProvider API Error (1041): Unknown error")
})

it("should still wrap non-abort request errors with the provider prefix", async () => {
mockCreate.mockImplementationOnce(() => {
throw new Error("boom")
})

const result = await captureError(
(async () => {
for await (const _ of handler.createMessage("system prompt", [])) {
// consume
}
})(),
)

expect(result.message).toBe("TestProvider completion error: boom")
})

it("should pass the completePrompt abort signal to the client request", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] })

const result = await handler.completePrompt("test prompt", { abortSignal: controller.signal })

expect(result).toBe("response")
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), {
signal: controller.signal,
})
})

it("should merge the completePrompt abort signal and timeoutMs into one request signal", async () => {
const controller = new AbortController()
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] })

await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })

const requestOptions = mockCreate.mock.calls.at(-1)?.[1]
expect(requestOptions?.signal).toBeInstanceOf(AbortSignal)
expect(requestOptions?.signal.aborted).toBe(false)
// A positive timeoutMs must be forwarded as the per-request SDK timeout.
expect(requestOptions?.timeout).toBe(5000)
// A timeout-only merged signal would still pass the assertions above;
// aborting the caller's controller proves caller cancellation survives.
controller.abort()
expect(requestOptions?.signal.aborted).toBe(true)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

it("should abort the request when a timeout-only completePrompt timeoutMs elapses", async () => {
// Emulate the OpenAI SDK: the pending request rejects when its signal aborts.
let capturedOptions: { signal?: AbortSignal; timeout?: number } | undefined
mockCreate.mockImplementationOnce(
async (_params: unknown, options?: { signal?: AbortSignal; timeout?: number }) => {
capturedOptions = options
await new Promise<void>((resolve) => {
if (options?.signal?.aborted) {
resolve()
} else {
options?.signal?.addEventListener("abort", () => resolve(), { once: true })
}
})
throw new APIUserAbortError()
},
)

// No caller signal: the timeout branch is exercised on its own. AbortSignal.timeout
// is backed by a native self-managed timer (not the fakeable global), so poll with
// vi.waitFor the same way abort-signal.spec.ts does.
const requestPromise = handler.completePrompt("test prompt", { timeoutMs: 50 })
// Attach the rejection handler immediately so the timeout rejection
// is never observed as unhandled while we poll for the abort.
const resultPromise = captureError(requestPromise)

await vi.waitFor(() => expect(capturedOptions?.signal?.aborted).toBe(true))
expect(capturedOptions?.timeout).toBe(50)

const result = await resultPromise
expect(result.name).toBe("AbortError")
expect(result.message).toBe("TestProvider request aborted")
})

it("should not set a request signal for zero completePrompt timeoutMs", async () => {
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] })

await handler.completePrompt("test prompt", { timeoutMs: 0 })

expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), undefined)
})

it("should reject before any request when the completePrompt signal is already aborted", async () => {
const controller = new AbortController()
controller.abort()

await expect(
handler.completePrompt("test prompt", { abortSignal: controller.signal }),
).rejects.toMatchObject({
name: "AbortError",
message: "This operation was aborted",
})
expect(mockCreate).not.toHaveBeenCalled()
})

it("should normalize the SDK APIUserAbortError from completePrompt", async () => {
mockCreate.mockImplementationOnce(() => {
throw new APIUserAbortError()
})

const result = await captureError(handler.completePrompt("test prompt"))

expect(result.name).toBe("AbortError")
expect(result.message).toBe("TestProvider request aborted")
})
})

describe("Tool call handling", () => {
it("should yield tool_call_end events when finish_reason is tool_calls", async () => {
mockCreate.mockImplementationOnce(() =>
Expand Down
29 changes: 29 additions & 0 deletions src/api/providers/__tests__/complete-prompt-options.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, it, expect } from "vitest"

import type { CompletePromptOptions } from "../../index"

describe("CompletePromptOptions", () => {
it("should allow abortSignal property", () => {
const controller = new AbortController()
const options: CompletePromptOptions = { abortSignal: controller.signal }
expect(options.abortSignal).toBe(controller.signal)
})

it("should allow timeoutMs property", () => {
const options: CompletePromptOptions = { timeoutMs: 5000 }
expect(options.timeoutMs).toBe(5000)
})

it("should allow both abortSignal and timeoutMs together", () => {
const controller = new AbortController()
const options: CompletePromptOptions = { abortSignal: controller.signal, timeoutMs: 10000 }
expect(options.abortSignal).toBe(controller.signal)
expect(options.timeoutMs).toBe(10000)
})

it("should allow empty options object", () => {
const options: CompletePromptOptions = {}
expect(options.abortSignal).toBeUndefined()
expect(options.timeoutMs).toBeUndefined()
})
})
2 changes: 2 additions & 0 deletions src/api/providers/__tests__/fireworks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ vi.mock("openai", () => ({
},
}
}),
// Named export consumed by the provider for abort-error normalization
APIUserAbortError: class extends Error {},
}))

describe("FireworksHandler", () => {
Expand Down
Loading
Loading