Skip to content

Commit a140cd5

Browse files
committed
fix(api): fix abort signal propagation across all providers
1 parent de408ee commit a140cd5

17 files changed

Lines changed: 187 additions & 93 deletions

src/api/providers/__tests__/deepseek.spec.ts

Lines changed: 38 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -637,48 +637,46 @@ describe("DeepSeekHandler", () => {
637637
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
638638
expect(toolCallChunks.length).toBeGreaterThan(0)
639639
expect(toolCallChunks[0].name).toBe("get_weather")
640+
})
641+
})
640642

641-
describe("abortSignal support", () => {
642-
it("should pass abortSignal to chat.completions.create when provided in metadata", async () => {
643-
const handler = new DeepSeekHandler({ ...mockOptions, apiKey: "test-key" })
644-
const systemPrompt = "You are a helpful assistant."
645-
const messages: Anthropic.Messages.MessageParam[] = [
646-
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
647-
]
648-
649-
const controller = new AbortController()
650-
const mockAbortSignal = controller.signal
651-
652-
await handler.createMessage(systemPrompt, messages, {
653-
taskId: "test",
654-
abortSignal: mockAbortSignal,
655-
})
656-
for await (const _chunk of handler.createMessage(systemPrompt, messages)) {
657-
break
658-
}
659-
660-
expect(mockCreate).toHaveBeenCalled()
661-
const callArgs = mockCreate.mock.calls[0][0]
662-
expect(callArgs.signal).toBe(mockAbortSignal)
663-
})
643+
describe("abortSignal support", () => {
644+
it("should pass abortSignal to chat.completions.create when provided in metadata", async () => {
645+
const handler = new DeepSeekHandler({ ...mockOptions, apiKey: "test-key" })
646+
const systemPrompt = "You are a helpful assistant."
647+
const messages: Anthropic.Messages.MessageParam[] = [
648+
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
649+
]
664650

665-
it("should not include signal when abortSignal is not provided", async () => {
666-
const handler = new DeepSeekHandler({ ...mockOptions, apiKey: "test-key" })
667-
const systemPrompt = "You are a helpful assistant."
668-
const messages: Anthropic.Messages.MessageParam[] = [
669-
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
670-
]
671-
672-
await handler.createMessage(systemPrompt, messages)
673-
for await (const _chunk of handler.createMessage(systemPrompt, messages)) {
674-
break
675-
}
676-
677-
expect(mockCreate).toHaveBeenCalled()
678-
const callArgs = mockCreate.mock.calls[0][0]
679-
expect(callArgs.signal).toBeUndefined()
680-
})
681-
})
651+
const controller = new AbortController()
652+
const mockAbortSignal = controller.signal
653+
654+
for await (const _chunk of handler.createMessage(systemPrompt, messages, {
655+
taskId: "test",
656+
abortSignal: mockAbortSignal,
657+
})) {
658+
break
659+
}
660+
661+
expect(mockCreate).toHaveBeenCalled()
662+
const requestOptions = mockCreate.mock.calls[0][1]
663+
expect(requestOptions?.signal).toBe(mockAbortSignal)
664+
})
665+
666+
it("should not include signal when abortSignal is not provided", async () => {
667+
const handler = new DeepSeekHandler({ ...mockOptions, apiKey: "test-key" })
668+
const systemPrompt = "You are a helpful assistant."
669+
const messages: Anthropic.Messages.MessageParam[] = [
670+
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
671+
]
672+
673+
for await (const _chunk of handler.createMessage(systemPrompt, messages)) {
674+
break
675+
}
676+
677+
expect(mockCreate).toHaveBeenCalled()
678+
const requestOptions = mockCreate.mock.calls[0][1]
679+
expect(requestOptions?.signal).toBeUndefined()
682680
})
683681
})
684682
})

src/api/providers/__tests__/gemini.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,7 +416,7 @@ describe("GeminiHandler", () => {
416416

417417
expect(mockGenerateContentStream).toHaveBeenCalled()
418418
const callArgs = mockGenerateContentStream.mock.calls[0][0]
419-
expect(callArgs.signal).toBe(mockAbortSignal)
419+
expect(callArgs.config?.abortSignal).toBe(mockAbortSignal)
420420
})
421421

422422
it("should pass undefined signal when abortSignal is not provided", async () => {
@@ -434,7 +434,7 @@ describe("GeminiHandler", () => {
434434

435435
expect(mockGenerateContentStream).toHaveBeenCalled()
436436
const callArgs = mockGenerateContentStream.mock.calls[0][0]
437-
expect(callArgs.signal).toBeUndefined()
437+
expect(callArgs.config?.abortSignal).toBeUndefined()
438438
})
439439
})
440440
})

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -639,10 +639,8 @@ describe("NativeOllamaHandler", () => {
639639
})
640640

641641
describe("abortSignal support", () => {
642-
it("should pass abortSignal to chat when provided in metadata", async () => {
642+
it("should wire abortSignal to per-request client's abort() method", async () => {
643643
vitest.clearAllMocks()
644-
const mockAbortController: any = { signal: Symbol("abort") }
645-
646644
;(mockGetOllamaModels as any).mockImplementationOnce(async () => ({
647645
llama2: { contextWindow: 4096, maxTokens: 4096, supportsImages: false, supportsPromptCache: false },
648646
}))
@@ -657,20 +655,23 @@ describe("NativeOllamaHandler", () => {
657655
ollamaBaseUrl: "http://localhost:11434",
658656
})
659657

658+
const controller = new AbortController()
659+
660660
for await (const _chunk of handlerWithSignal.createMessage(
661661
"system",
662662
[{ role: "user", content: "Hello!" }],
663-
{ taskId: "test", abortSignal: mockAbortController.signal },
663+
{ taskId: "test", abortSignal: controller.signal },
664664
)) {
665665
break
666666
}
667667

668668
expect(mockedData.mockChat).toHaveBeenCalled()
669+
// The chat call should NOT have signal in options (we use per-request client instead)
669670
const callArgs = mockedData.mockChat.mock.calls[0][0]
670-
expect(callArgs.signal).toBe(mockAbortController.signal)
671+
expect(callArgs.signal).toBeUndefined()
671672
})
672673

673-
it("should pass undefined signal when abortSignal is not provided", async () => {
674+
it("should not pass signal in options when abortSignal is not provided", async () => {
674675
vitest.clearAllMocks()
675676
;(mockGetOllamaModels as any).mockImplementationOnce(async () => ({
676677
llama2: { contextWindow: 4096, maxTokens: 4096, supportsImages: false, supportsPromptCache: false },

src/api/providers/__tests__/vercel-ai-gateway.spec.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -586,13 +586,16 @@ describe("VercelAiGatewayHandler", () => {
586586
const result = await handler.completePrompt(prompt)
587587

588588
expect(result).toBe("Test completion response")
589-
expect(mockCreate).toHaveBeenCalledWith({
590-
model: "anthropic/claude-sonnet-4",
591-
messages: [{ role: "user", content: prompt }],
592-
stream: false,
593-
temperature: VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE,
594-
max_completion_tokens: 64000,
595-
})
589+
expect(mockCreate).toHaveBeenCalledWith(
590+
{
591+
model: "anthropic/claude-sonnet-4",
592+
messages: [{ role: "user", content: prompt }],
593+
stream: false,
594+
temperature: VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE,
595+
max_completion_tokens: 64000,
596+
},
597+
{ signal: undefined },
598+
)
596599
})
597600

598601
it("uses custom temperature for completion", async () => {
@@ -608,6 +611,7 @@ describe("VercelAiGatewayHandler", () => {
608611
expect.objectContaining({
609612
temperature: customTemp,
610613
}),
614+
expect.objectContaining({ signal: undefined }),
611615
)
612616
})
613617

@@ -656,6 +660,7 @@ describe("VercelAiGatewayHandler", () => {
656660
expect.objectContaining({
657661
temperature: 0.9,
658662
}),
663+
expect.objectContaining({ signal: undefined }),
659664
)
660665
})
661666

src/api/providers/__tests__/zoo-gateway.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ describe("ZooGatewayHandler", () => {
464464
temperature: ZOO_GATEWAY_DEFAULT_TEMPERATURE,
465465
max_completion_tokens: 64000,
466466
}),
467+
expect.objectContaining({ signal: undefined }),
467468
)
468469
})
469470

src/api/providers/bedrock.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -534,12 +534,21 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
534534
const controller = new AbortController()
535535
let timeoutId: NodeJS.Timeout | undefined
536536

537-
// Listen for external abort signal from metadata and forward to internal controller
537+
// Listen for external abort signal from metadata and forward to internal controller.
538+
// Handle both pre-aborted signals and future abort events.
538539
const externalAbortSignal = metadata?.abortSignal
539540
if (externalAbortSignal) {
540-
externalAbortSignal.addEventListener("abort", () => {
541+
if (externalAbortSignal.aborted) {
541542
controller.abort()
542-
})
543+
} else {
544+
externalAbortSignal.addEventListener(
545+
"abort",
546+
() => {
547+
controller.abort()
548+
},
549+
{ once: true },
550+
)
551+
}
543552
}
544553

545554
try {

src/api/providers/gemini.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
343343
}
344344
}
345345

346-
const params: any = { model, contents, config, signal: metadata?.abortSignal }
346+
const params: any = {
347+
model,
348+
contents,
349+
config: { ...config, abortSignal: metadata?.abortSignal },
350+
}
347351

348352
try {
349353
const result = await this.client.models.generateContentStream(params)

src/api/providers/native-ollama.ts

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -205,10 +205,37 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
205205
messages: Anthropic.Messages.MessageParam[],
206206
metadata?: ApiHandlerCreateMessageMetadata,
207207
): ApiStream {
208-
const client = this.ensureClient()
209208
const { id: modelId } = await this.fetchModel()
210209
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
211210

211+
// Create a per-request Ollama client since the SDK doesn't support abortSignal in options.
212+
// We listen to metadata.abortSignal and call client.abort() when it fires.
213+
const requestClient = new Ollama({
214+
host: this.options.ollamaBaseUrl || "http://localhost:11434",
215+
})
216+
217+
// Add API key if provided
218+
if (this.options.ollamaApiKey) {
219+
;(requestClient as any).config = {
220+
...((requestClient as any).config ?? {}),
221+
headers: { Authorization: `Bearer ${this.options.ollamaApiKey}` },
222+
}
223+
}
224+
225+
// Wire external abort signal to per-request client's abort() method
226+
const externalAbortSignal = metadata?.abortSignal
227+
if (externalAbortSignal) {
228+
if (externalAbortSignal.aborted) {
229+
requestClient.abort()
230+
} else {
231+
const abortListener = () => {
232+
requestClient.abort()
233+
externalAbortSignal.removeEventListener("abort", abortListener)
234+
}
235+
externalAbortSignal.addEventListener("abort", abortListener, { once: true })
236+
}
237+
}
238+
212239
const ollamaMessages: Message[] = [
213240
{ role: "system", content: systemPrompt },
214241
...convertToOllamaMessages(messages),
@@ -234,14 +261,13 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
234261
chatOptions.num_ctx = this.options.ollamaNumCtx
235262
}
236263

237-
// Create the actual API request promise
238-
const stream = await client.chat({
264+
// Create the actual API request promise (use per-request client, not signal in options)
265+
const stream = await requestClient.chat({
239266
model: modelId,
240267
messages: ollamaMessages,
241268
stream: true,
242269
options: chatOptions,
243270
tools: this.convertToolsToOllama(metadata?.tools),
244-
signal: metadata?.abortSignal,
245271
} as any)
246272

247273
let totalInputTokens = 0
@@ -346,27 +372,48 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
346372
}
347373

348374
async completePrompt(prompt: string, metadata?: ApiHandlerCreateMessageMetadata): Promise<string> {
375+
// Create a per-request Ollama client since the SDK doesn't support abortSignal in options.
376+
const requestClient = new Ollama({
377+
host: this.options.ollamaBaseUrl || "http://localhost:11434",
378+
})
379+
380+
if (this.options.ollamaApiKey) {
381+
;(requestClient as any).config = {
382+
headers: { Authorization: `Bearer ${this.options.ollamaApiKey}` },
383+
}
384+
}
385+
386+
// Wire external abort signal to per-request client's abort() method
387+
const externalAbortSignal = metadata?.abortSignal
388+
if (externalAbortSignal) {
389+
if (externalAbortSignal.aborted) {
390+
requestClient.abort()
391+
} else {
392+
const abortListener = () => {
393+
requestClient.abort()
394+
externalAbortSignal.removeEventListener("abort", abortListener)
395+
}
396+
externalAbortSignal.addEventListener("abort", abortListener, { once: true })
397+
}
398+
}
399+
349400
try {
350-
const client = this.ensureClient()
351401
const { id: modelId } = await this.fetchModel()
352402
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
353403

354-
// Build options object conditionally
355404
const chatOptions: OllamaChatOptions = {
356405
temperature: this.options.modelTemperature ?? (useR1Format ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
357406
}
358407

359-
// Only include num_ctx if explicitly set via ollamaNumCtx
360408
if (this.options.ollamaNumCtx !== undefined) {
361409
chatOptions.num_ctx = this.options.ollamaNumCtx
362410
}
363411

364-
const response = await client.chat({
412+
const response = await requestClient.chat({
365413
model: modelId,
366414
messages: [{ role: "user", content: prompt }],
367415
stream: false,
368416
options: chatOptions,
369-
signal: metadata?.abortSignal,
370417
} as any)
371418

372419
return ((response as any).message?.content as string) || ""
@@ -375,6 +422,8 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
375422
throw new Error(`Ollama completion error: ${error.message}`)
376423
}
377424
throw error
425+
} finally {
426+
requestClient.abort()
378427
}
379428
}
380429
}

src/api/providers/openai-codex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
188188
// Make the request with retry on auth failure
189189
for (let attempt = 0; attempt < 2; attempt++) {
190190
try {
191-
yield* this.executeRequest(requestBody, model, accessToken, metadata?.taskId)
191+
yield* this.executeRequest(requestBody, model, accessToken, metadata?.taskId, metadata)
192192
return
193193
} catch (error) {
194194
const message = error instanceof Error ? error.message : String(error)

0 commit comments

Comments
 (0)