Skip to content

Commit fe4adfc

Browse files
committed
test(providers): add completePrompt signal/timeout tests for all 25 providers
Add comprehensive test coverage for the updated completePrompt interface that now accepts CompletePromptOptions { signal?, timeoutMs? }. Each provider spec includes signal passthrough, timeout passthrough, and backward compatible tests. Updated implementations: - requesty.ts: add options parameter support for signal/timeout Test patterns by provider type: - OpenAI Client Pattern: signal + timeout as second argument - Anthropic SDK Pattern: signal as third argument - Responses API: signal as second argument - AI SDK (generateText): signal in options object - VSCode LM: bridge AbortSignal to CancellationToken - Bedrock: pass abortSignal to client.send - Gemini: httpOptions.signal in config - OpenAI Native: merge signals with RequestConfigBuilder
1 parent 3270b7d commit fe4adfc

56 files changed

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

43+
export interface CompletePromptOptions {
44+
/** Abort signal for cancelling the request mid-flight */
45+
signal?: AbortSignal
46+
/** Optional timeout override (ms) — falls back to provider default if omitted */
47+
timeoutMs?: number
48+
}
49+
4350
export interface SingleCompletionHandler {
44-
completePrompt(prompt: string): Promise<string>
51+
completePrompt(prompt: string, options?: CompletePromptOptions): Promise<string>
4552
}
4653

4754
export interface ApiHandlerCreateMessageMetadata {

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

Lines changed: 52 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,42 @@ 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", { signal: 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+
})
898938
})
899939

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

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

Lines changed: 62 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,57 @@ 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", { signal: 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", { signal: 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+
{ signal: controller.signal },
519+
)
520+
})
467521
})
468522

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

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,48 @@ describe("AwsBedrockHandler", () => {
15761576
expect(isAdaptiveThinkingModel("anthropic.claude-3-5-sonnet-20241022-v2:0")).toBe(false)
15771577
expect(isAdaptiveThinkingModel("amazon.nova-lite-v1:0")).toBe(false)
15781578
})
1579+
1580+
it("should pass abort signal through to client.send", async () => {
1581+
const mockConverseCommand = vi.mocked(ConverseCommand)
1582+
const mockSend = BedrockRuntimeClient.prototype.send as any
1583+
1584+
const handler = new AwsBedrockHandler({
1585+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
1586+
awsAccessKey: "test-access-key",
1587+
awsSecretKey: "test-secret-key",
1588+
awsRegion: "us-east-1",
1589+
})
1590+
1591+
const controller = new AbortController()
1592+
mockSend.mockResolvedValueOnce({
1593+
output: { message: { content: [{ type: "text", text: "response" }] }, stopReason: null },
1594+
})
1595+
1596+
await handler.completePrompt("test prompt", { signal: controller.signal })
1597+
1598+
expect(mockSend).toHaveBeenCalledWith(expect.any(Object), { abortSignal: controller.signal })
1599+
})
1600+
1601+
it("should work without options (backward compatible)", async () => {
1602+
const mockConverseCommand = vi.mocked(ConverseCommand)
1603+
const mockSend = BedrockRuntimeClient.prototype.send as any
1604+
1605+
const handler = new AwsBedrockHandler({
1606+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
1607+
awsAccessKey: "test-access-key",
1608+
awsSecretKey: "test-secret-key",
1609+
awsRegion: "us-east-1",
1610+
})
1611+
1612+
mockSend.mockResolvedValueOnce({
1613+
output: { message: { content: [{ type: "text", text: "response" }] }, stopReason: null },
1614+
})
1615+
1616+
const result = await handler.completePrompt("test prompt")
1617+
1618+
expect(result).toBe("response")
1619+
expect(mockSend).toHaveBeenCalledWith(expect.any(Object), undefined)
1620+
})
15791621
})
15801622
})
15811623
})
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, it, expect } from "vitest"
2+
3+
import type { CompletePromptOptions } from "../../index"
4+
5+
describe("CompletePromptOptions", () => {
6+
it("should allow signal property", () => {
7+
const controller = new AbortController()
8+
const options: CompletePromptOptions = { signal: controller.signal }
9+
expect(options.signal).toBe(controller.signal)
10+
})
11+
12+
it("should allow timeoutMs property", () => {
13+
const options: CompletePromptOptions = { timeoutMs: 5000 }
14+
expect(options.timeoutMs).toBe(5000)
15+
})
16+
17+
it("should allow both signal and timeoutMs together", () => {
18+
const controller = new AbortController()
19+
const options: CompletePromptOptions = { signal: controller.signal, timeoutMs: 10000 }
20+
expect(options.signal).toBe(controller.signal)
21+
expect(options.timeoutMs).toBe(10000)
22+
})
23+
24+
it("should allow empty options object", () => {
25+
const options: CompletePromptOptions = {}
26+
expect(options.signal).toBeUndefined()
27+
expect(options.timeoutMs).toBeUndefined()
28+
})
29+
})

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -710,4 +710,45 @@ describe("DeepSeekHandler", () => {
710710
expect(toolCallChunks[0].name).toBe("get_weather")
711711
})
712712
})
713+
714+
describe("completePrompt", () => {
715+
it("should complete prompt successfully", async () => {
716+
mockCreate.mockResolvedValueOnce({
717+
choices: [{ message: { content: "response" } }],
718+
})
719+
const result = await handler.completePrompt("test prompt")
720+
expect(result).toBe("response")
721+
})
722+
723+
it("should pass abort signal through to client", async () => {
724+
const controller = new AbortController()
725+
mockCreate.mockResolvedValueOnce({
726+
choices: [{ message: { content: "response" } }],
727+
})
728+
await handler.completePrompt("test prompt", { signal: controller.signal })
729+
expect(mockCreate).toHaveBeenCalledWith(
730+
expect.objectContaining({ model: expect.any(String) }),
731+
expect.objectContaining({ signal: controller.signal }),
732+
)
733+
})
734+
735+
it("should pass timeout through to client", async () => {
736+
mockCreate.mockResolvedValueOnce({
737+
choices: [{ message: { content: "response" } }],
738+
})
739+
await handler.completePrompt("test prompt", { timeoutMs: 5000 })
740+
expect(mockCreate).toHaveBeenCalledWith(
741+
expect.objectContaining({ model: expect.any(String) }),
742+
expect.objectContaining({ timeout: 5000 }),
743+
)
744+
})
745+
746+
it("should work without options (backward compatible)", async () => {
747+
mockCreate.mockResolvedValueOnce({
748+
choices: [{ message: { content: "response" } }],
749+
})
750+
const result = await handler.completePrompt("test prompt")
751+
expect(result).toBe("response")
752+
})
753+
})
713754
})

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,31 @@ describe("FireworksHandler", () => {
609609
expect(result).toBe("")
610610
})
611611

612+
it("completePrompt should pass abort signal through to client", async () => {
613+
const controller = new AbortController()
614+
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] })
615+
await handler.completePrompt("test prompt", { signal: controller.signal })
616+
expect(mockCreate).toHaveBeenCalledWith(
617+
expect.objectContaining({ model: expect.any(String) }),
618+
expect.objectContaining({ signal: controller.signal }),
619+
)
620+
})
621+
622+
it("completePrompt should pass timeout through to client", async () => {
623+
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] })
624+
await handler.completePrompt("test prompt", { timeoutMs: 5000 })
625+
expect(mockCreate).toHaveBeenCalledWith(
626+
expect.objectContaining({ model: expect.any(String) }),
627+
expect.objectContaining({ timeout: 5000 }),
628+
)
629+
})
630+
631+
it("completePrompt should work without options (backward compatible)", async () => {
632+
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: "response" } }] })
633+
const result = await handler.completePrompt("test prompt")
634+
expect(result).toBe("response")
635+
})
636+
612637
it("createMessage should handle stream with multiple chunks", async () => {
613638
mockCreate.mockImplementationOnce(async () => ({
614639
[Symbol.asyncIterator]: async function* () {

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,44 @@ describe("GeminiHandler backend support", () => {
5555
expect(promptConfig.tools).toBeUndefined()
5656
})
5757

58+
it("completePrompt should pass abort signal through to client via httpOptions", async () => {
59+
const options = {
60+
apiProvider: "gemini",
61+
enableUrlContext: false,
62+
enableGrounding: false,
63+
} as ApiHandlerOptions
64+
const handler = new GeminiHandler(options)
65+
66+
const controller = new AbortController()
67+
const stub = vi.fn().mockResolvedValue({ text: "response" })
68+
handler["client"].models.generateContent = stub
69+
70+
await handler.completePrompt("test prompt", { signal: controller.signal })
71+
72+
expect(stub).toHaveBeenCalledWith(
73+
expect.objectContaining({
74+
config: expect.objectContaining({
75+
httpOptions: { signal: controller.signal },
76+
}),
77+
}),
78+
)
79+
})
80+
81+
it("completePrompt should work without options (backward compatible)", async () => {
82+
const options = {
83+
apiProvider: "gemini",
84+
enableUrlContext: false,
85+
enableGrounding: false,
86+
} as ApiHandlerOptions
87+
const handler = new GeminiHandler(options)
88+
89+
const stub = vi.fn().mockResolvedValue({ text: "response" })
90+
handler["client"].models.generateContent = stub
91+
92+
const result = await handler.completePrompt("test prompt")
93+
expect(result).toBe("response")
94+
})
95+
5896
describe("error scenarios", () => {
5997
it("should handle grounding metadata extraction failure gracefully", async () => {
6098
const options = {

0 commit comments

Comments
 (0)