Skip to content

Commit 71c9508

Browse files
committed
feat(api): implement abort/timeout support for native-ollama provider
1 parent 96f1edc commit 71c9508

3 files changed

Lines changed: 142 additions & 34 deletions

File tree

src/api/providers/__tests__/native-ollama.spec.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ import { getOllamaModels } from "../fetchers/ollama"
88

99
// Mock the ollama package
1010
const mockChat = vitest.fn()
11+
const mockAbort = vitest.fn()
1112
vitest.mock("ollama", () => {
1213
return {
13-
Ollama: vitest.fn().mockImplementation(function () {
14+
Ollama: vitest.fn().mockImplementation(function (options?: any) {
1415
return {
1516
chat: mockChat,
17+
abort: mockAbort,
18+
_host: options?.host ?? "http://localhost:11434",
1619
}
1720
}),
1821
Message: vitest.fn(),
@@ -366,6 +369,78 @@ describe("NativeOllamaHandler", () => {
366369
const result = await handler.completePrompt("Test prompt")
367370
expect(result).toBe("Response")
368371
})
372+
373+
it("should call client.abort() when timeoutMs is reached", async () => {
374+
const testTimeout = 5000
375+
let capturedFn: (() => void) | undefined
376+
377+
vitest.spyOn(global, "setTimeout").mockImplementation((fn: any, ms?: number) => {
378+
if (ms === testTimeout) {
379+
capturedFn = fn as () => void
380+
return 0 as any
381+
}
382+
return 0 as any
383+
})
384+
385+
mockChat.mockResolvedValue({
386+
message: { content: "Response" },
387+
})
388+
389+
await handler.completePrompt("Test prompt", { timeoutMs: testTimeout })
390+
391+
expect(capturedFn).toBeDefined()
392+
if (capturedFn) capturedFn()
393+
expect(mockAbort).toHaveBeenCalledTimes(1)
394+
})
395+
396+
it("should call client.abort() when abortSignal is aborted", async () => {
397+
const controller = new AbortController()
398+
mockChat.mockResolvedValue({
399+
message: { content: "Response" },
400+
})
401+
402+
const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal })
403+
controller.abort()
404+
await promise
405+
406+
expect(mockAbort).toHaveBeenCalledTimes(1)
407+
})
408+
409+
it("should call client.abort() immediately when abortSignal is already aborted", async () => {
410+
const controller = new AbortController()
411+
controller.abort()
412+
413+
mockChat.mockResolvedValue({
414+
message: { content: "Response" },
415+
})
416+
417+
await handler.completePrompt("Test prompt", { abortSignal: controller.signal })
418+
419+
expect(mockAbort).toHaveBeenCalledTimes(1)
420+
})
421+
422+
it("should clear timeoutId in finally block on success", async () => {
423+
let capturedDelay: number | undefined
424+
425+
vitest.spyOn(global, "setTimeout").mockImplementation((fn: any, ms?: number) => {
426+
if (ms === 5000) {
427+
capturedDelay = ms
428+
return 1 as any // Return truthy value so timeoutId is set
429+
}
430+
return 0 as any
431+
})
432+
433+
vitest.spyOn(global, "clearTimeout").mockImplementation(() => {})
434+
435+
mockChat.mockResolvedValue({
436+
message: { content: "Response" },
437+
})
438+
439+
await handler.completePrompt("Test prompt", { timeoutMs: 5000 })
440+
441+
// setTimeout should have been called with the correct delay
442+
expect(capturedDelay).toBe(5000)
443+
})
369444
})
370445

371446
describe("error handling", () => {

src/api/providers/__tests__/openai-native.spec.ts

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -279,36 +279,36 @@ describe("OpenAiNativeHandler", () => {
279279
expect(result).toBe("response")
280280
})
281281

282-
it("completePrompt should pass timeoutMs through to client", async () => {
283-
mockResponsesCreate.mockResolvedValue({
284-
output: [
285-
{
286-
type: "message",
287-
content: [{ type: "output_text", text: "response" }],
288-
},
289-
],
290-
})
291-
292-
await handler.completePrompt("Test prompt", { timeoutMs: 5000 })
293-
// Implementation creates an AbortSignal when timeoutMs is provided.
294-
expect(mockResponsesCreate.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal)
295-
})
296-
297-
it("completePrompt should merge signal and timeoutMs together", async () => {
298-
const controller = new AbortController()
299-
mockResponsesCreate.mockResolvedValue({
300-
output: [
301-
{
302-
type: "message",
303-
content: [{ type: "output_text", text: "response" }],
304-
},
305-
],
306-
})
307-
308-
await handler.completePrompt("Test prompt", { abortSignal: controller.signal, timeoutMs: 10000 })
309-
// Implementation uses AbortSignal.any() to merge signals.
310-
expect(mockResponsesCreate.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal)
311-
})
282+
it("completePrompt should pass timeoutMs through to client", async () => {
283+
mockResponsesCreate.mockResolvedValue({
284+
output: [
285+
{
286+
type: "message",
287+
content: [{ type: "output_text", text: "response" }],
288+
},
289+
],
290+
})
291+
292+
await handler.completePrompt("Test prompt", { timeoutMs: 5000 })
293+
// Implementation passes a signal to the client (uses baseSignal when no abortSignal provided).
294+
expect(mockResponsesCreate.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal)
295+
})
296+
297+
it("completePrompt should merge signal and timeoutMs together", async () => {
298+
const controller = new AbortController()
299+
mockResponsesCreate.mockResolvedValue({
300+
output: [
301+
{
302+
type: "message",
303+
content: [{ type: "output_text", text: "response" }],
304+
},
305+
],
306+
})
307+
308+
await handler.completePrompt("Test prompt", { abortSignal: controller.signal, timeoutMs: 10000 })
309+
// Implementation uses AbortSignal.any() to merge signals.
310+
expect(mockResponsesCreate.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal)
311+
})
312312
})
313313

314314
describe("getModel", () => {

src/api/providers/native-ollama.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -347,13 +347,42 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
347347
}
348348
}
349349

350-
async completePrompt(prompt: string, _options?: CompletePromptOptions): Promise<string> {
351-
// Ollama native client doesn't support abort signals at all — accept param but ignore
350+
async completePrompt(prompt: string, options?: CompletePromptOptions): Promise<string> {
351+
// Ollama native client doesn't support external AbortSignal directly.
352+
// For per-request cancellation, create a dedicated client instance when abortSignal is provided.
353+
const hasAbortSignal = options?.abortSignal !== undefined
354+
let localClient: Ollama | undefined
355+
let timeoutId: ReturnType<typeof setTimeout> | undefined
356+
352357
try {
353-
const client = this.ensureClient()
358+
// Use local client if abortSignal is provided (per-request isolation)
359+
const client = hasAbortSignal
360+
? (localClient ??= new Ollama({ host: this.options.ollamaBaseUrl }))
361+
: this.ensureClient()
354362
const { id: modelId } = await this.fetchModel()
355363
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
356364

365+
// Handle timeoutMs if provided
366+
if (options?.timeoutMs !== undefined && options.timeoutMs > 0) {
367+
timeoutId = setTimeout(() => client.abort(), options.timeoutMs)
368+
}
369+
370+
// Propagate abortSignal into the local controller via client.abort()
371+
if (options?.abortSignal) {
372+
if (options.abortSignal.aborted) {
373+
client.abort()
374+
} else {
375+
options.abortSignal.addEventListener(
376+
"abort",
377+
() => {
378+
client.abort()
379+
clearTimeout(timeoutId)
380+
},
381+
{ once: true },
382+
)
383+
}
384+
}
385+
357386
// Build options object conditionally
358387
const chatOptions: OllamaChatOptions = {
359388
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
@@ -377,6 +406,10 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
377406
throw new Error(`Ollama completion error: ${error.message}`)
378407
}
379408
throw error
409+
} finally {
410+
if (timeoutId) {
411+
clearTimeout(timeoutId)
412+
}
380413
}
381414
}
382415
}

0 commit comments

Comments
 (0)