Skip to content

Commit 05219ef

Browse files
committed
feat(api): add abort signal support for API providers and task (#434)
- Add AbortController support to openai-compatible provider - Pass abort signal through Task execution chain - Add comprehensive tests for abort signal behavior across all providers
1 parent 6bcd398 commit 05219ef

54 files changed

Lines changed: 1945 additions & 141 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

src/api/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ export interface ApiHandlerCreateMessageMetadata {
9090
* Only applies to providers that support function calling restrictions (e.g., Gemini).
9191
*/
9292
allowedFunctionNames?: string[]
93+
/**
94+
* Abort signal for cancelling the HTTP request mid-stream.
95+
* Passed through to AI SDK's streamText() so the underlying HTTP request is aborted
96+
* when the user clicks stop, preventing wasted API tokens/compute on the provider side.
97+
*/
98+
abortSignal?: AbortSignal
9399
}
94100

95101
export interface ApiHandler {

src/api/providers/__tests__/anthropic-vertex.spec.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1540,4 +1540,60 @@ describe("VertexHandler", () => {
15401540
})
15411541
})
15421542
})
1543+
1544+
describe("abortSignal support", () => {
1545+
it("should pass abortSignal to messages.create when provided in metadata", async () => {
1546+
const handler = new AnthropicVertexHandler({
1547+
apiModelId: "claude-3-5-sonnet-v2@20241022",
1548+
vertexProjectId: "test-project",
1549+
vertexRegion: "us-central1",
1550+
})
1551+
1552+
const mockCreate = handler["client"].messages.create as any
1553+
mockCreate.mockClear()
1554+
1555+
const controller = new AbortController()
1556+
const mockAbortSignal = controller.signal
1557+
1558+
const systemPrompt = "You are a helpful assistant."
1559+
const messages: Anthropic.Messages.MessageParam[] = [
1560+
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
1561+
]
1562+
1563+
for await (const _chunk of handler.createMessage(systemPrompt, messages, {
1564+
taskId: "test",
1565+
abortSignal: mockAbortSignal,
1566+
})) {
1567+
break
1568+
}
1569+
1570+
expect(mockCreate).toHaveBeenCalled()
1571+
const callArgs = mockCreate.mock.calls[0][1]
1572+
expect(callArgs?.signal).toBe(mockAbortSignal)
1573+
})
1574+
1575+
it("should pass undefined signal when abortSignal is not provided", async () => {
1576+
const handler = new AnthropicVertexHandler({
1577+
apiModelId: "claude-3-5-sonnet-v2@20241022",
1578+
vertexProjectId: "test-project",
1579+
vertexRegion: "us-central1",
1580+
})
1581+
1582+
const mockCreate = handler["client"].messages.create as any
1583+
mockCreate.mockClear()
1584+
1585+
const systemPrompt = "You are a helpful assistant."
1586+
const messages: Anthropic.Messages.MessageParam[] = [
1587+
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
1588+
]
1589+
1590+
for await (const _chunk of handler.createMessage(systemPrompt, messages)) {
1591+
break
1592+
}
1593+
1594+
expect(mockCreate).toHaveBeenCalled()
1595+
const callArgs = mockCreate.mock.calls[0][1]
1596+
expect(callArgs?.signal).toBeUndefined()
1597+
})
1598+
})
15431599
})

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,4 +1055,57 @@ describe("AnthropicHandler", () => {
10551055
})
10561056
})
10571057
})
1058+
1059+
describe("abortSignal support", () => {
1060+
it("should pass abortSignal to messages.create when provided in metadata", async () => {
1061+
const handler = new AnthropicHandler({
1062+
apiKey: "test-api-key",
1063+
apiModelId: "claude-3-5-sonnet-20241022",
1064+
})
1065+
1066+
const controller = new AbortController()
1067+
const mockAbortSignal = controller.signal
1068+
1069+
mockCreate.mockResolvedValueOnce({
1070+
[Symbol.asyncIterator]: async function* () {
1071+
yield { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 5 } } }
1072+
},
1073+
})
1074+
1075+
for await (const _chunk of handler.createMessage(
1076+
"system",
1077+
[{ role: "user", content: [{ type: "text", text: "Hello!" }] }],
1078+
{ taskId: "test", abortSignal: mockAbortSignal },
1079+
)) {
1080+
break
1081+
}
1082+
1083+
expect(mockCreate).toHaveBeenCalled()
1084+
const callArgs = mockCreate.mock.calls[0][1]
1085+
expect(callArgs?.signal).toBe(mockAbortSignal)
1086+
})
1087+
1088+
it("should pass undefined signal when abortSignal is not provided", async () => {
1089+
const handler = new AnthropicHandler({
1090+
apiKey: "test-api-key",
1091+
apiModelId: "claude-3-5-sonnet-20241022",
1092+
})
1093+
1094+
mockCreate.mockResolvedValueOnce({
1095+
[Symbol.asyncIterator]: async function* () {
1096+
yield { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 5 } } }
1097+
},
1098+
})
1099+
1100+
for await (const _chunk of handler.createMessage("system", [
1101+
{ role: "user", content: [{ type: "text", text: "Hello!" }] },
1102+
])) {
1103+
break
1104+
}
1105+
1106+
expect(mockCreate).toHaveBeenCalled()
1107+
const callArgs = mockCreate.mock.calls[0][1]
1108+
expect(callArgs?.signal).toBeUndefined()
1109+
})
1110+
})
10581111
})

src/api/providers/__tests__/base-openai-compatible-provider.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ describe("BaseOpenAiCompatibleProvider", () => {
358358
stream: true,
359359
stream_options: { include_usage: true },
360360
}),
361-
undefined,
361+
expect.any(Object),
362362
)
363363
})
364364

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,48 @@ 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+
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+
})
664+
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+
})
640682
})
641683
})
642684
})

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

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -95,25 +95,46 @@ describe("FireworksHandler", () => {
9595
})
9696

9797
it.each([
98-
{ modelId: "accounts/fireworks/models/glm-5p1" as const, contextWindow: 202752, inputPrice: 1.4, outputPrice: 4.4, cacheReadsPrice: 0.26 },
99-
{ modelId: "accounts/fireworks/models/kimi-k2p6" as const, contextWindow: 262144, inputPrice: 0.95, outputPrice: 4.0, cacheReadsPrice: 0.16 },
100-
{ modelId: "accounts/fireworks/models/deepseek-v4-pro" as const, contextWindow: 1048576, inputPrice: 1.74, outputPrice: 3.48, cacheReadsPrice: 0.14 },
101-
])("should expose newly added model $modelId", ({ modelId, contextWindow, inputPrice, outputPrice, cacheReadsPrice }) => {
102-
expect(fireworksModels[modelId]).toBeDefined()
103-
const info = fireworksModels[modelId]
104-
expect(info.maxTokens).toBeGreaterThan(0)
105-
expect(info.contextWindow).toBe(contextWindow)
106-
expect(info.inputPrice).toBe(inputPrice)
107-
expect(info.outputPrice).toBe(outputPrice)
108-
expect(info.cacheReadsPrice).toBe(cacheReadsPrice)
109-
expect(info.description).toBeTruthy()
110-
111-
const handlerWithModel = new FireworksHandler({
112-
apiModelId: modelId,
113-
fireworksApiKey: "test-fireworks-api-key",
114-
})
115-
expect(handlerWithModel.getModel().id).toBe(modelId)
116-
})
98+
{
99+
modelId: "accounts/fireworks/models/glm-5p1" as const,
100+
contextWindow: 202752,
101+
inputPrice: 1.4,
102+
outputPrice: 4.4,
103+
cacheReadsPrice: 0.26,
104+
},
105+
{
106+
modelId: "accounts/fireworks/models/kimi-k2p6" as const,
107+
contextWindow: 262144,
108+
inputPrice: 0.95,
109+
outputPrice: 4.0,
110+
cacheReadsPrice: 0.16,
111+
},
112+
{
113+
modelId: "accounts/fireworks/models/deepseek-v4-pro" as const,
114+
contextWindow: 1048576,
115+
inputPrice: 1.74,
116+
outputPrice: 3.48,
117+
cacheReadsPrice: 0.14,
118+
},
119+
])(
120+
"should expose newly added model $modelId",
121+
({ modelId, contextWindow, inputPrice, outputPrice, cacheReadsPrice }) => {
122+
expect(fireworksModels[modelId]).toBeDefined()
123+
const info = fireworksModels[modelId]
124+
expect(info.maxTokens).toBeGreaterThan(0)
125+
expect(info.contextWindow).toBe(contextWindow)
126+
expect(info.inputPrice).toBe(inputPrice)
127+
expect(info.outputPrice).toBe(outputPrice)
128+
expect(info.cacheReadsPrice).toBe(cacheReadsPrice)
129+
expect(info.description).toBeTruthy()
130+
131+
const handlerWithModel = new FireworksHandler({
132+
apiModelId: modelId,
133+
fireworksApiKey: "test-fireworks-api-key",
134+
})
135+
expect(handlerWithModel.getModel().id).toBe(modelId)
136+
},
137+
)
117138

118139
it("should return Kimi K2 Instruct model with correct configuration", () => {
119140
const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct"
@@ -465,7 +486,7 @@ describe("FireworksHandler", () => {
465486
stream: true,
466487
stream_options: { include_usage: true },
467488
}),
468-
undefined,
489+
expect.any(Object),
469490
)
470491
})
471492

@@ -491,7 +512,7 @@ describe("FireworksHandler", () => {
491512
expect.objectContaining({
492513
temperature: 0.5,
493514
}),
494-
undefined,
515+
expect.any(Object),
495516
)
496517
})
497518

@@ -518,7 +539,7 @@ describe("FireworksHandler", () => {
518539
expect.objectContaining({
519540
temperature: 1.0,
520541
}),
521-
undefined,
542+
expect.any(Object),
522543
)
523544
})
524545

@@ -546,7 +567,7 @@ describe("FireworksHandler", () => {
546567
expect.objectContaining({
547568
temperature: 0.7,
548569
}),
549-
undefined,
570+
expect.any(Object),
550571
)
551572
})
552573

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,4 +366,48 @@ describe("GeminiHandler", () => {
366366
expect(mockCaptureException).toHaveBeenCalled()
367367
})
368368
})
369+
370+
describe("abortSignal support", () => {
371+
it("should pass abortSignal to generateContentStream when provided in metadata", async () => {
372+
const mockGenerateContentStream = vitest.fn().mockResolvedValue({
373+
[Symbol.asyncIterator]: async function* () {
374+
yield { text: "Hello" }
375+
},
376+
})
377+
378+
handler["client"].models.generateContentStream = mockGenerateContentStream
379+
380+
const controller = new AbortController()
381+
const mockAbortSignal = controller.signal
382+
383+
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "Hello!" }], {
384+
taskId: "test",
385+
abortSignal: mockAbortSignal,
386+
})) {
387+
break
388+
}
389+
390+
expect(mockGenerateContentStream).toHaveBeenCalled()
391+
const callArgs = mockGenerateContentStream.mock.calls[0][0]
392+
expect(callArgs.signal).toBe(mockAbortSignal)
393+
})
394+
395+
it("should pass undefined signal when abortSignal is not provided", async () => {
396+
const mockGenerateContentStream = vitest.fn().mockResolvedValue({
397+
[Symbol.asyncIterator]: async function* () {
398+
yield { text: "Hello" }
399+
},
400+
})
401+
402+
handler["client"].models.generateContentStream = mockGenerateContentStream
403+
404+
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "Hello!" }])) {
405+
break
406+
}
407+
408+
expect(mockGenerateContentStream).toHaveBeenCalled()
409+
const callArgs = mockGenerateContentStream.mock.calls[0][0]
410+
expect(callArgs.signal).toBeUndefined()
411+
})
412+
})
369413
})

src/api/providers/__tests__/lite-llm.spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,4 +1115,58 @@ describe("LiteLLMHandler", () => {
11151115
expect(id1).not.toBe(id2)
11161116
})
11171117
})
1118+
1119+
describe("abortSignal support", () => {
1120+
const mockStream = {
1121+
async *[Symbol.asyncIterator]() {
1122+
yield {
1123+
choices: [{ delta: { content: "test response" } }],
1124+
}
1125+
},
1126+
}
1127+
1128+
beforeEach(() => {
1129+
mockCreate.mockReturnValue({
1130+
withResponse: vi.fn().mockResolvedValue({ data: mockStream }),
1131+
})
1132+
})
1133+
1134+
it("should pass abortSignal to chat.completions.create when provided in metadata", async () => {
1135+
const handler = new LiteLLMHandler(mockOptions)
1136+
const systemPrompt = "You are a helpful assistant."
1137+
const messages: Anthropic.Messages.MessageParam[] = [
1138+
{ role: "user", content: [{ type: "text", text: "Hello!" }] },
1139+
]
1140+
1141+
const controller = new AbortController()
1142+
const mockAbortSignal = controller.signal
1143+
1144+
for await (const _chunk of handler.createMessage(systemPrompt, messages, {
1145+
taskId: "test",
1146+
abortSignal: mockAbortSignal,
1147+
})) {
1148+
break
1149+
}
1150+
1151+
expect(mockCreate).toHaveBeenCalled()
1152+
const callArgs = mockCreate.mock.calls[0][1]
1153+
expect(callArgs?.signal).toBe(mockAbortSignal)
1154+
})
1155+
1156+
it("should pass undefined signal when abortSignal is not provided", async () => {
1157+
const handler = new LiteLLMHandler(mockOptions)
1158+
const systemPrompt = "You are a helpful assistant."
1159+
const messages: Anthropic.Messages.MessageParam[] = [
1160+
{ role: "user", content: [{ type: "text", text: "Hello!" }] },
1161+
]
1162+
1163+
for await (const _chunk of handler.createMessage(systemPrompt, messages)) {
1164+
break
1165+
}
1166+
1167+
expect(mockCreate).toHaveBeenCalled()
1168+
const callArgs = mockCreate.mock.calls[0][1]
1169+
expect(callArgs?.signal).toBeUndefined()
1170+
})
1171+
})
11181172
})

0 commit comments

Comments
 (0)