Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
e61feb1
feat(api): add throwIfAborted helper and completePrompt options regre…
easonliang28 Aug 19, 2026
4497268
feat(api): abort-signal wiring for lm-studio and qwen-code (round 2)
easonliang28 Aug 20, 2026
9d2942f
Merge branch 'main' into feat/abort-r2-lmstudio-qwen
easonLiangWorldedtech Aug 20, 2026
e272e4d
fix(api): align lm-studio specs with abort-signal call shape and addr…
easonLiangWorldedtech Aug 20, 2026
abd9827
test(api): cover LM Studio reasoning_content delta in abort-signal spec
easonLiangWorldedtech Aug 20, 2026
6e1e1af
test(api): cover qwen-code degenerate stream shapes for full branch c…
easonLiangWorldedtech Aug 20, 2026
a0117fb
feat(api): add shared isRequestAborted and createAbortError helpers t…
easonLiangWorldedtech Aug 21, 2026
56b10d5
Merge branch 'feat/abort-r1-foundation' into feat/abort-r2-lmstudio-qwen
easonLiangWorldedtech Aug 21, 2026
e57e88b
refactor(api): use shared abort helpers from foundation utils in lm-s…
easonLiangWorldedtech Aug 21, 2026
c235479
fix(api): normalize aborts during qwen-code 401 retry and lm-studio t…
easonLiangWorldedtech Aug 21, 2026
5ac4e51
test(api): cover the non-abort path of the qwen-code 401 retry
easonLiangWorldedtech Aug 21, 2026
7f26495
Merge branch 'main' into feat/abort-r2-lmstudio-qwen
easonLiangWorldedtech Sep 1, 2026
5e3a575
Merge branch 'main' into feat/abort-r2-lmstudio-qwen
easonLiangWorldedtech Sep 2, 2026
37ef142
Merge upstream/main (79cd12f2c) into feat/abort-r2-lmstudio-qwen
easonliang28 Sep 3, 2026
78345cf
fix(ci): declare vitest as a root devDependency so the mutation gate…
easonliang28 Sep 3, 2026
6db3e70
test(api): strengthen focused tests for the changed-code mutation gate
easonliang28 Sep 3, 2026
0df30b9
test(api): harden abort-wiring tests against hanging mutants
easonliang28 Sep 3, 2026
87beca0
test(api): exclude proven-equivalent Stryker mutants from the changed…
easonliang28 Sep 3, 2026
3cd0261
test(api): exclude unobservable inner-abort provider-name literals fr…
easonliang28 Sep 3, 2026
8b154df
fix(api): settle lm-studio and qwen-code wait points against the abor…
easonliang28 Sep 5, 2026
00e369f
Merge remote-tracking branch 'upstream/main' into feat/abort-r2-lmstu…
easonliang28 Sep 5, 2026
2f8aab0
test(api): add abort kill-tests for lm-studio and qwen-code
easonliang28 Sep 5, 2026
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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"rimraf": "6.0.1",
"tsx": "4.22.4",
"turbo": "2.10.0",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "4.1.9"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,json,css,md}": [
Expand Down
4 changes: 4 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

299 changes: 293 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, collectStream } 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,278 @@ 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$/)
})

it("should stream reasoning chunks from a reasoning_content delta", async () => {
// Changed-line coverage regression: reasoning models served by LM Studio
// stream thinking via delta.reasoning_content, and createMessage must yield
// a reasoning chunk from that dedicated field.
const handler = new LmStudioHandler(options)
vitest.spyOn(handler, "countTokens").mockResolvedValue(1)
const create = lastCreate()
create.mockResolvedValue(
asyncStreamFrom([
{ choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] },
{ choices: [{ delta: { content: "answer" }, index: 0 }] },
{
choices: [{ delta: {}, index: 0 }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
},
]),
)

const chunks = await collectStream(handler.createMessage("system", []))

expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." })
expect(chunks).toContainEqual({ type: "text", text: "answer" })
})
})

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