Skip to content

Commit 7b2213d

Browse files
committed
feat(api): add abort signal support to all providers and tests
1 parent 80fb159 commit 7b2213d

62 files changed

Lines changed: 2934 additions & 180 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: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,17 @@ import {
4040
} from "./providers"
4141
import { NativeOllamaHandler } from "./providers/native-ollama"
4242

43+
/**
44+
* Options for completePrompt — unified with ApiHandlerCreateMessageMetadata.
45+
* Uses abortSignal (not signal) to match the metadata pattern used in stream path.
46+
*/
47+
export interface CompletePromptOptions extends Pick<ApiHandlerCreateMessageMetadata, "abortSignal"> {
48+
/** Optional timeout override (ms) — falls back to provider default if omitted */
49+
timeoutMs?: number
50+
}
51+
4352
export interface SingleCompletionHandler {
44-
completePrompt(prompt: string): Promise<string>
53+
completePrompt(prompt: string, metadata?: CompletePromptOptions): Promise<string>
4554
}
4655

4756
export interface ApiHandlerCreateMessageMetadata {

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

Lines changed: 79 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -834,18 +834,22 @@ describe("VertexHandler", () => {
834834

835835
const result = await handler.completePrompt("Test prompt")
836836
expect(result).toBe("Test response")
837-
expect(handler["client"].messages.create).toHaveBeenCalledWith({
838-
model: "claude-3-5-sonnet-v2@20241022",
839-
max_tokens: 8192,
840-
temperature: 0,
841-
messages: [
842-
{
843-
role: "user",
844-
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
845-
},
846-
],
847-
stream: false,
848-
})
837+
expect(handler["client"].messages.create).toHaveBeenCalledWith(
838+
{
839+
model: "claude-3-5-sonnet-v2@20241022",
840+
max_tokens: 8192,
841+
temperature: 0,
842+
messages: [
843+
{
844+
role: "user",
845+
content: [{ type: "text", text: "Test prompt", cache_control: { type: "ephemeral" } }],
846+
},
847+
],
848+
stream: false,
849+
thinking: undefined,
850+
},
851+
undefined,
852+
)
849853
})
850854

851855
it("should handle API errors for Claude", async () => {
@@ -895,6 +899,69 @@ describe("VertexHandler", () => {
895899
const result = await handler.completePrompt("Test prompt")
896900
expect(result).toBe("")
897901
})
902+
903+
it("should pass abort signal through to client", async () => {
904+
handler = new AnthropicVertexHandler({
905+
apiModelId: "claude-3-5-sonnet-v2@20241022",
906+
vertexProjectId: "test-project",
907+
vertexRegion: "us-central1",
908+
})
909+
910+
const controller = new AbortController()
911+
const mockCreate = vitest.fn().mockResolvedValue({
912+
content: [{ type: "text", text: "response" }],
913+
})
914+
;(handler["client"].messages as any).create = mockCreate
915+
916+
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
917+
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), {
918+
signal: controller.signal,
919+
})
920+
})
921+
922+
it("should work without options (backward compatible)", async () => {
923+
handler = new AnthropicVertexHandler({
924+
apiModelId: "claude-3-5-sonnet-v2@20241022",
925+
vertexProjectId: "test-project",
926+
vertexRegion: "us-central1",
927+
})
928+
929+
const mockCreate = vitest.fn().mockResolvedValue({
930+
content: [{ type: "text", text: "response" }],
931+
})
932+
;(handler["client"].messages as any).create = mockCreate
933+
934+
const result = await handler.completePrompt("test prompt")
935+
expect(result).toBe("response")
936+
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), undefined)
937+
})
938+
939+
it("completePrompt should pass signal through to client", async () => {
940+
const controller = new AbortController()
941+
const mockCreate = vitest.fn().mockResolvedValue({
942+
content: [{ type: "text", text: "response" }],
943+
})
944+
;(handler["client"].messages as any).create = mockCreate
945+
946+
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
947+
expect(mockCreate).toHaveBeenCalledWith(
948+
expect.objectContaining({ model: expect.any(String) }),
949+
expect.objectContaining({ signal: controller.signal, timeout: 5000 }),
950+
)
951+
})
952+
953+
it("completePrompt should pass timeoutMs when provided", async () => {
954+
const mockCreate = vitest.fn().mockResolvedValue({
955+
content: [{ type: "text", text: "response" }],
956+
})
957+
;(handler["client"].messages as any).create = mockCreate
958+
959+
await handler.completePrompt("test prompt", { timeoutMs: 3000 })
960+
expect(mockCreate).toHaveBeenCalledWith(
961+
expect.objectContaining({ model: expect.any(String) }),
962+
expect.objectContaining({ timeout: 3000 }),
963+
)
964+
})
898965
})
899966

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

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

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -434,14 +434,17 @@ describe("AnthropicHandler", () => {
434434
it("should complete prompt successfully", async () => {
435435
const result = await handler.completePrompt("Test prompt")
436436
expect(result).toBe("Test response")
437-
expect(mockCreate).toHaveBeenCalledWith({
438-
model: mockOptions.apiModelId,
439-
messages: [{ role: "user", content: "Test prompt" }],
440-
max_tokens: 8192,
441-
temperature: 0,
442-
thinking: undefined,
443-
stream: false,
444-
})
437+
expect(mockCreate).toHaveBeenCalledWith(
438+
{
439+
model: mockOptions.apiModelId,
440+
messages: [{ role: "user", content: "Test prompt" }],
441+
max_tokens: 8192,
442+
temperature: 0,
443+
thinking: undefined,
444+
stream: false,
445+
},
446+
undefined,
447+
)
445448
})
446449

447450
it("should handle API errors", async () => {
@@ -464,6 +467,86 @@ describe("AnthropicHandler", () => {
464467
const result = await handler.completePrompt("Test prompt")
465468
expect(result).toBe("")
466469
})
470+
471+
it("should pass abort signal through to client", async () => {
472+
const controller = new AbortController()
473+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
474+
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
475+
expect(mockCreate).toHaveBeenCalledWith(
476+
{
477+
model: mockOptions.apiModelId,
478+
messages: [{ role: "user", content: "test prompt" }],
479+
max_tokens: 8192,
480+
temperature: 0,
481+
thinking: undefined,
482+
stream: false,
483+
},
484+
{ signal: controller.signal },
485+
)
486+
})
487+
488+
it("should work without options (backward compatible)", async () => {
489+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
490+
const result = await handler.completePrompt("test prompt")
491+
expect(result).toBe("response")
492+
expect(mockCreate).toHaveBeenCalledWith(
493+
{
494+
model: mockOptions.apiModelId,
495+
messages: [{ role: "user", content: "test prompt" }],
496+
max_tokens: 8192,
497+
temperature: 0,
498+
thinking: undefined,
499+
stream: false,
500+
},
501+
undefined,
502+
)
503+
})
504+
505+
it("should merge signal and timeout together", async () => {
506+
const controller = new AbortController()
507+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
508+
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 10000 })
509+
expect(mockCreate).toHaveBeenCalledWith(
510+
{
511+
model: mockOptions.apiModelId,
512+
messages: [{ role: "user", content: "test prompt" }],
513+
max_tokens: 8192,
514+
temperature: 0,
515+
thinking: undefined,
516+
stream: false,
517+
},
518+
expect.objectContaining({ signal: controller.signal, timeout: 10000 }),
519+
)
520+
})
521+
522+
it("should pass timeoutMs through to client alongside abortSignal", async () => {
523+
const controller = new AbortController()
524+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
525+
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
526+
expect(mockCreate).toHaveBeenCalledWith(
527+
expect.objectContaining({ model: mockOptions.apiModelId }),
528+
expect.objectContaining({ signal: controller.signal, timeout: 5000 }),
529+
)
530+
})
531+
532+
it("should pass the same signal instance", async () => {
533+
const controller = new AbortController()
534+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
535+
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
536+
expect(mockCreate).toHaveBeenCalledWith(
537+
expect.any(Object),
538+
expect.objectContaining({ signal: controller.signal }),
539+
)
540+
// Verify it's the exact same instance, not just equal
541+
const callOptions = mockCreate.mock.calls[0][1]
542+
expect(callOptions?.signal).toBe(controller.signal)
543+
})
544+
545+
it("should not include signal-related options when not provided", async () => {
546+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
547+
await handler.completePrompt("test prompt")
548+
expect(mockCreate).toHaveBeenCalledWith(expect.any(Object), undefined)
549+
})
467550
})
468551

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

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,58 @@ describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => {
116116
}),
117117
)
118118
})
119+
120+
describe("completePrompt", () => {
121+
it("should pass timeout through to client when both signal and timeoutMs provided", async () => {
122+
const handler = new TestOpenAiCompatibleProvider("test-api-key")
123+
const controller = new AbortController()
124+
const mockCreate = vitest.fn().mockResolvedValue({
125+
choices: [{ message: { content: "response" } }],
126+
})
127+
handler["client"].chat.completions.create = mockCreate
128+
129+
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
130+
expect(mockCreate).toHaveBeenCalledWith(
131+
expect.objectContaining({ model: "test-model" }),
132+
expect.objectContaining({ signal: expect.any(AbortSignal), timeout: 5000 }),
133+
)
134+
})
135+
136+
it("should pass only timeoutMs when no signal provided", async () => {
137+
const handler = new TestOpenAiCompatibleProvider("test-api-key")
138+
const mockCreate = vitest.fn().mockResolvedValue({
139+
choices: [{ message: { content: "response" } }],
140+
})
141+
handler["client"].chat.completions.create = mockCreate
142+
143+
await handler.completePrompt("test prompt", { timeoutMs: 3000 })
144+
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { timeout: 3000 })
145+
})
146+
147+
it("should handle timeoutMs=0 as valid value (!== undefined check)", async () => {
148+
const handler = new TestOpenAiCompatibleProvider("test-api-key")
149+
const mockCreate = vitest.fn().mockResolvedValue({
150+
choices: [{ message: { content: "response" } }],
151+
})
152+
handler["client"].chat.completions.create = mockCreate
153+
154+
await handler.completePrompt("test prompt", { timeoutMs: 0 })
155+
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ model: "test-model" }), { timeout: 0 })
156+
})
157+
158+
it("should work without options (backward compatible)", async () => {
159+
const handler = new TestOpenAiCompatibleProvider("test-api-key")
160+
const mockCreate = vitest.fn().mockResolvedValue({
161+
choices: [{ message: { content: "response" } }],
162+
})
163+
handler["client"].chat.completions.create = mockCreate
164+
165+
const result = await handler.completePrompt("test prompt")
166+
expect(result).toBe("response")
167+
expect(mockCreate).toHaveBeenCalledWith(
168+
expect.objectContaining({ model: "test-model" }),
169+
{}, // empty object when no options
170+
)
171+
})
172+
})
119173
})

0 commit comments

Comments
 (0)