Skip to content

Commit d0d5d8f

Browse files
committed
test(providers): add completePrompt signal/timeout tests for all 25 providers
- anthropic.spec.ts: timeout trigger test + signal instance verification - native-ollama.spec.ts: explicit assertion no second arg passed (no signal forwarding) - openai-codex-native-tool-calls.spec.ts: removed weak toHaveProperty(signal) assertion - vercel-ai-gateway.spec.ts: temperature test uses correct undefined second arg fix: add timeoutMs forwarding to completePrompt methods - vercel-ai-gateway.ts: use Object.keys(createOptions).length > 0 check instead of truthy check - poe.ts: merge signal and timeoutMs properly with combined abort logic - config-builder/README.md: update documentation for mergeAbortSignals behavior test: add timeoutMs coverage for poe, moonshot, minimax, mistral, xai providers - poe.spec.ts: signal+timeoutMs merge, timeoutMs only, timeoutMs=0 cases - moonshot.spec.ts: same timeoutMs tests for openai-compatible pattern - minimax.spec.ts: signal+timeoutMs, timeoutMs only, truthy check behavior - mistral.spec.ts: same timeoutMs coverage - xai.spec.ts: signal+timeoutMs, timeoutMs only, truthy check behavior test: add timeoutMs coverage for anthropic-vertex, base-openai-compatible, bedrock, openai-native - anthropic-vertex.spec.ts: signal passing test (no timeoutMs support) - base-openai-compatible-provider-timeout.spec.ts: completePrompt with signal+timeoutMs, timeoutMs only, truthy check behavior - bedrock.spec.ts: timeoutMs coverage for adaptive thinking path - openai-native.spec.ts: signal and timeoutMs merging tests test: add signal+timeoutMs merge tests for fireworks, lite-llm, lmstudio - fireworks.spec.ts: added merge signal and timeoutMs together test - lite-llm.spec.ts: added same combined signal+timeoutMs test - lmstudio.spec.ts: aligned with same abort signal pattern fix: replace tautological assertion in poe.spec.ts - poe.spec.ts: completePrompt should prefer signal over timeoutMs test now asserts abortSignal is a distinct AbortSignal from controller.signal instead of always-true instanceof check test: add missing error catch and timeout cleanup tests - vscode-lm.spec.ts: added 'should handle errors in completePrompt' test - poe.spec.ts: added 'completePrompt should clear timeout when user signal aborts' test - opencode-go.spec.ts: added OpenAI path completePrompt tests (signal, timeoutMs, merged)
1 parent 3270b7d commit d0d5d8f

61 files changed

Lines changed: 2246 additions & 163 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: 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", { 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+
})
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", { signal: controller.signal, timeoutMs: 5000 })
947+
expect(mockCreate).toHaveBeenCalledWith(
948+
expect.objectContaining({ model: expect.any(String) }),
949+
{ signal: controller.signal }, // only signal is passed, not timeoutMs
950+
)
951+
})
952+
953+
it("completePrompt should not pass timeoutMs when no signal 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+
undefined, // anthropic-vertex only passes signal, not timeoutMs
963+
)
964+
})
898965
})
899966

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

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

Lines changed: 110 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,105 @@ 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+
expect.objectContaining({ signal: controller.signal, timeout: 10000 }),
519+
)
520+
})
521+
522+
it("should trigger timeout when timeoutMs elapses before request completes", async () => {
523+
const mockCreateWithTimeout = vitest
524+
.fn()
525+
.mockImplementation(
526+
async () =>
527+
new Promise((resolve) =>
528+
setTimeout(() => resolve({ content: [{ type: "text", text: "response" }] }), 500),
529+
),
530+
)
531+
532+
const handlerTimeout = new AnthropicHandler(mockOptions)
533+
// Replace the mock on the existing handler's client
534+
handlerTimeout["client"].messages.create = mockCreateWithTimeout
535+
536+
const controller = new AbortController()
537+
let timeoutTriggered = false
538+
handlerTimeout.completePrompt("test prompt", { signal: controller.signal, timeoutMs: 50 }).catch(() => {
539+
timeoutTriggered = true
540+
})
541+
542+
// Wait for timeout to trigger (50ms timeout + buffer)
543+
await new Promise((resolve) => setTimeout(resolve, 150))
544+
545+
// Verify the API was called with timeout options
546+
expect(mockCreateWithTimeout).toHaveBeenCalled()
547+
// User signal should not be aborted (timeout mechanism aborts its own internal signal)
548+
expect(controller.signal.aborted).toBe(false)
549+
})
550+
551+
it("should pass the same signal instance", async () => {
552+
const controller = new AbortController()
553+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
554+
await handler.completePrompt("test prompt", { signal: controller.signal })
555+
expect(mockCreate).toHaveBeenCalledWith(
556+
expect.any(Object),
557+
expect.objectContaining({ signal: controller.signal }),
558+
)
559+
// Verify it's the exact same instance, not just equal
560+
const callOptions = mockCreate.mock.calls[0][1]
561+
expect(callOptions?.signal).toBe(controller.signal)
562+
})
563+
564+
it("should not include signal-related options when not provided", async () => {
565+
mockCreate.mockResolvedValueOnce({ content: [{ type: "text", text: "response" }] })
566+
await handler.completePrompt("test prompt")
567+
expect(mockCreate).toHaveBeenCalledWith(expect.any(Object), undefined)
568+
})
467569
})
468570

469571
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", { signal: 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
})

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

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,79 @@ 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 mockSend = vi.fn()
1582+
1583+
const handler = new AwsBedrockHandler({
1584+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
1585+
awsAccessKey: "test-access-key",
1586+
awsSecretKey: "test-secret-key",
1587+
awsRegion: "us-east-1",
1588+
})
1589+
1590+
// Set up the mock on the handler's client instance directly
1591+
const clientInstance = (handler as any).client
1592+
expect(clientInstance).toBeDefined()
1593+
clientInstance.send = mockSend
1594+
1595+
const controller = new AbortController()
1596+
mockSend.mockResolvedValueOnce({
1597+
output: { message: { content: [{ type: "text", text: "response" }] }, stopReason: null },
1598+
})
1599+
1600+
await handler.completePrompt("test prompt", { signal: controller.signal })
1601+
1602+
expect(mockSend).toHaveBeenCalledWith(expect.any(Object), { abortSignal: controller.signal })
1603+
})
1604+
1605+
it("should work without options (backward compatible)", async () => {
1606+
const mockSend = vi.fn()
1607+
1608+
const handler = new AwsBedrockHandler({
1609+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
1610+
awsAccessKey: "test-access-key",
1611+
awsSecretKey: "test-secret-key",
1612+
awsRegion: "us-east-1",
1613+
})
1614+
1615+
const clientInstance = (handler as any).client
1616+
expect(clientInstance).toBeDefined()
1617+
clientInstance.send = mockSend
1618+
1619+
mockSend.mockResolvedValueOnce({
1620+
output: { message: { content: [{ type: "text", text: "response" }] }, stopReason: null },
1621+
})
1622+
1623+
const result = await handler.completePrompt("test prompt")
1624+
1625+
expect(result).toBe("response")
1626+
expect(mockSend).toHaveBeenCalledWith(expect.any(Object), undefined)
1627+
})
1628+
1629+
it("completePrompt should pass timeoutMs through to client", async () => {
1630+
const mockSend = vi.fn()
1631+
1632+
const handler = new AwsBedrockHandler({
1633+
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
1634+
awsAccessKey: "test-access-key",
1635+
awsSecretKey: "test-secret-key",
1636+
awsRegion: "us-east-1",
1637+
})
1638+
1639+
const clientInstance = (handler as any).client
1640+
expect(clientInstance).toBeDefined()
1641+
clientInstance.send = mockSend
1642+
1643+
mockSend.mockResolvedValueOnce({
1644+
output: { message: { content: [{ type: "text", text: "response" }] }, stopReason: null },
1645+
})
1646+
1647+
await handler.completePrompt("test prompt", { timeoutMs: 5000 })
1648+
1649+
// bedrock.ts uses truthy check for timeoutMs, so it creates AbortSignal.timeout
1650+
expect(mockSend).toHaveBeenCalled()
1651+
})
15791652
})
15801653
})
15811654
})

0 commit comments

Comments
 (0)