Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
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()
})
})
275 changes: 269 additions & 6 deletions src/api/providers/__tests__/lm-studio-timeout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,33 @@ vitest.mock("../utils/timeout-config", () => ({
import { getApiRequestTimeout } from "../utils/timeout-config"

import { clearAllMocks } from "../../../test-utils/reset"
import { asyncStreamFrom } from "../../../test-utils/stream"

// Mock OpenAI
interface MockOpenAiClient {
chat: {
completions: {
create: ReturnType<typeof vitest.fn>
}
}
}

// Mock OpenAI (records each created client so tests can drive its create call)
const mockOpenAIConstructor = vitest.fn()
const createdClients: MockOpenAiClient[] = []
vitest.mock("openai", () => {
return {
__esModule: true,
default: vitest.fn().mockImplementation(function (config) {
mockOpenAIConstructor(config)
return {
const client: MockOpenAiClient = {
chat: {
completions: {
create: vitest.fn(),
},
},
}
createdClients.push(client)
mockOpenAIConstructor(config)
return client
}),
}
})
Expand All @@ -36,7 +48,7 @@ describe("LmStudioHandler timeout configuration", () => {
})

it("should use default timeout of 600 seconds when no configuration is set", () => {
;(getApiRequestTimeout as any).mockReturnValue(600000)
vitest.mocked(getApiRequestTimeout).mockReturnValue(600000)

const options: ApiHandlerOptions = {
apiModelId: "llama2",
Expand All @@ -57,7 +69,7 @@ describe("LmStudioHandler timeout configuration", () => {
})

it("should use custom timeout when configuration is set", () => {
;(getApiRequestTimeout as any).mockReturnValue(1200000) // 20 minutes
vitest.mocked(getApiRequestTimeout).mockReturnValue(1200000) // 20 minutes

const options: ApiHandlerOptions = {
apiModelId: "llama2",
Expand All @@ -75,7 +87,7 @@ describe("LmStudioHandler timeout configuration", () => {
})

it("should handle zero timeout (no timeout)", () => {
;(getApiRequestTimeout as any).mockReturnValue(0)
vitest.mocked(getApiRequestTimeout).mockReturnValue(0)

const options: ApiHandlerOptions = {
apiModelId: "llama2",
Expand All @@ -91,3 +103,254 @@ describe("LmStudioHandler timeout configuration", () => {
)
})
})

describe("LmStudioHandler abort signal wiring", () => {
let options: ApiHandlerOptions

// Mirror the OpenAI SDK's APIUserAbortError shape: name "Error", message
// "Request was aborted." It does not satisfy the Task.ts abort contract
// (message must end in "aborted"), so the provider must normalize it.
const sdkAbortError = (): Error => {
const err = new Error("Request was aborted.")
err.name = "Error"
return err
}

const waitForCreateCall = async (create: { mock: { calls: unknown[][] } }, timeoutMs = 5000): Promise<void> => {
const start = Date.now()
while (create.mock.calls.length === 0) {
if (Date.now() - start > timeoutMs) {
throw new Error("timed out waiting for the SDK create call")
}
await new Promise((resolve) => setTimeout(resolve, 5))
}
}

const waitForSignalAbort = (signal: AbortSignal | undefined): Promise<void> => {
return new Promise((resolve, reject) => {
if (!signal) {
reject(new Error("SDK create was called without a signal"))
return
}
if (signal.aborted) {
resolve()
return
}
signal.addEventListener("abort", () => resolve(), { once: true })
})
}

const lastCreate = (): MockOpenAiClient["chat"]["completions"]["create"] => {
const client = createdClients[createdClients.length - 1]
if (!client) {
throw new Error("no OpenAI client was created")
}
return client.chat.completions.create
}

beforeEach(() => {
clearAllMocks()
vitest.mocked(getApiRequestTimeout).mockReturnValue(600000)
options = {
apiModelId: "llama2",
lmStudioModelId: "llama2",
lmStudioBaseUrl: "http://localhost:1234",
}
})

describe("createMessage", () => {
it("should pass a request-local AbortSignal to the SDK and bridge the external signal", async () => {
const handler = new LmStudioHandler(options)
vitest.spyOn(handler, "countTokens").mockResolvedValue(1)
const create = lastCreate()
create.mockResolvedValue(asyncStreamFrom([]))

const external = new AbortController()
const stream = handler.createMessage("system", [], { taskId: "t1", abortSignal: external.signal })
await stream.next()

const opts = create.mock.calls[0][1]
expect(opts?.signal).toBeInstanceOf(AbortSignal)
expect(opts.signal).not.toBe(external.signal) // request-local, not the external signal
expect(opts.signal.aborted).toBe(false)

external.abort()
expect(opts.signal.aborted).toBe(true) // the external abort is bridged to the SDK signal

await stream.next() // drain the generator
})

it("should fast-fail with a normalized AbortError when the signal is pre-aborted", async () => {
const handler = new LmStudioHandler(options)
const create = lastCreate()
const external = new AbortController()
external.abort()

const stream = handler.createMessage("system", [], { taskId: "t1", abortSignal: external.signal })
let caught: unknown
try {
await stream.next()
} catch (error) {
caught = error
}

expect(caught).toBeInstanceOf(Error)
expect((caught as Error).name).toBe("AbortError")
expect((caught as Error).message).toMatch(/aborted$/)
expect(create).not.toHaveBeenCalled()
})

it("should abort the in-flight SDK request when the external signal fires", async () => {
const handler = new LmStudioHandler(options)
vitest.spyOn(handler, "countTokens").mockResolvedValue(1)
const create = lastCreate()
// Simulate the OpenAI SDK: reject with its abort error when the signal aborts.
create.mockImplementation((_params: unknown, opts?: { signal?: AbortSignal }) => {
return new Promise((_resolve, reject) => {
if (!opts?.signal) {
reject(new Error("SDK create was called without a signal"))
return
}
opts.signal.addEventListener("abort", () => reject(sdkAbortError()), { once: true })
})
})

const external = new AbortController()
const stream = handler.createMessage("system", [], { taskId: "t1", abortSignal: external.signal })
const pending = stream.next()
await waitForCreateCall(create)
external.abort()

let caught: unknown
try {
await pending
} catch (error) {
caught = error
}

expect(caught).toBeInstanceOf(Error)
expect((caught as Error).name).toBe("AbortError")
expect((caught as Error).message).toMatch(/aborted$/)
})

it("should normalize an abort error thrown mid-stream", async () => {
const handler = new LmStudioHandler(options)
vitest.spyOn(handler, "countTokens").mockResolvedValue(1)
const create = lastCreate()
const external = new AbortController()
// Simulate the OpenAI SDK stream: yield once, then reject with its
// abort error once the request-local signal is aborted.
create.mockImplementation((_params: unknown, opts?: { signal?: AbortSignal }) => {
return (async function* () {
yield { choices: [{ delta: { content: "partial" } }] }
await waitForSignalAbort(opts?.signal)
throw sdkAbortError()
})()
})

const stream = handler.createMessage("system", [], { taskId: "t1", abortSignal: external.signal })
const chunks: { type: string; text?: string }[] = []
let caught: unknown
try {
for await (const chunk of stream) {
chunks.push(chunk)
if (chunk.type === "text") {
external.abort()
}
}
} catch (error) {
caught = error
}

expect(chunks).toContainEqual({ type: "text", text: "partial" })
expect(caught).toBeInstanceOf(Error)
expect((caught as Error).name).toBe("AbortError")
expect((caught as Error).message).toMatch(/aborted$/)
})
})

describe("completePrompt", () => {
it("should pass the external signal through, and nothing without a signal or with a zero timeout", async () => {
const handler = new LmStudioHandler(options)
const create = lastCreate()
create.mockResolvedValue({ choices: [{ message: { content: "ok" } }] })
const external = new AbortController()

expect(await handler.completePrompt("hi")).toBe("ok")
expect(create.mock.calls[0][1]).toBeUndefined() // no signal, no timeout: nothing reaches the SDK

expect(await handler.completePrompt("hi", { abortSignal: external.signal })).toBe("ok")
// no timeout: the merged signal is the external signal itself
expect(create.mock.calls[1][1]?.signal).toBe(external.signal)

// timeoutMs <= 0 means "no explicit timeout": nothing may reach the SDK
expect(await handler.completePrompt("hi", { timeoutMs: 0 })).toBe("ok")
expect(create.mock.calls[2][1]).toBeUndefined()
})

it("should merge the external signal with a positive timeoutMs", async () => {
const handler = new LmStudioHandler(options)
const create = lastCreate()
create.mockImplementation((_params: unknown, opts?: { signal?: AbortSignal }) => {
return new Promise((_resolve, reject) => {
const signal = opts?.signal
if (!signal) {
reject(new Error("SDK create was called without a signal"))
return
}
signal.addEventListener("abort", () => reject(sdkAbortError()), { once: true })
})
})
const external = new AbortController()

const pending = handler.completePrompt("hi", { abortSignal: external.signal, timeoutMs: 60_000 })
const opts = create.mock.calls[0][1]
expect(opts?.signal).toBeInstanceOf(AbortSignal)
expect(opts.signal).not.toBe(external.signal) // merged via AbortSignal.any
expect(opts.signal.aborted).toBe(false)

external.abort()
let caught: unknown
try {
await pending
} catch (error) {
caught = error
}

expect((caught as Error).name).toBe("AbortError")
expect((caught as Error).message).toMatch(/aborted$/)
})

it("should normalize SDK abort errors instead of wrapping them", async () => {
const handler = new LmStudioHandler(options)
const create = lastCreate()
create.mockRejectedValue(sdkAbortError())

let caught: unknown
try {
await handler.completePrompt("hi")
} catch (error) {
caught = error
}

expect((caught as Error).name).toBe("AbortError")
expect((caught as Error).message).toMatch(/aborted$/)
})

it("should keep wrapping non-abort errors in the LM Studio debug message", async () => {
const handler = new LmStudioHandler(options)
const create = lastCreate()
create.mockRejectedValue(new Error("boom"))

let caught: unknown
try {
await handler.completePrompt("hi")
} catch (error) {
caught = error
}

expect(caught).toBeInstanceOf(Error)
expect((caught as Error).message).toContain("Please check the LM Studio developer logs")
})
})
})
Loading
Loading