Skip to content

Commit 5ca9150

Browse files
committed
test(providers): cover provider completion options
1 parent 70cdaf2 commit 5ca9150

3 files changed

Lines changed: 181 additions & 4 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import type { Anthropic } from "@anthropic-ai/sdk"
2+
3+
import type { ApiHandlerCreateMessageMetadata, CompletePromptOptions } from "../../index"
4+
import { FakeAIHandler } from "../fake-ai"
5+
6+
const modelInfo = {
7+
contextWindow: 8192,
8+
maxTokens: 4096,
9+
supportsImages: false,
10+
supportsPromptCache: false,
11+
}
12+
13+
describe("FakeAIHandler", () => {
14+
it("should delegate completePrompt with options to the cached FakeAI instance", async () => {
15+
const completePrompt = vitest.fn().mockResolvedValue("delegated response")
16+
const fakeAi: {
17+
id: string
18+
createMessage: () => AsyncGenerator<never, void, unknown>
19+
getModel: () => { id: string; info: typeof modelInfo }
20+
countTokens: ReturnType<typeof vitest.fn>
21+
completePrompt: typeof completePrompt
22+
removeFromCache?: () => void
23+
} = {
24+
id: "fake-ai-completePrompt-delegation",
25+
createMessage: async function* () {},
26+
getModel: () => ({ id: "fake-model", info: modelInfo }),
27+
countTokens: vitest.fn().mockResolvedValue(0),
28+
completePrompt,
29+
}
30+
const controller = new AbortController()
31+
const options: CompletePromptOptions = { abortSignal: controller.signal, timeoutMs: 1234 }
32+
33+
const handler = new FakeAIHandler({ fakeAi })
34+
const result = await handler.completePrompt("Test prompt", options)
35+
36+
expect(result).toBe("delegated response")
37+
expect(completePrompt).toHaveBeenCalledWith("Test prompt", options)
38+
fakeAi.removeFromCache?.()
39+
})
40+
41+
it("should delegate createMessage, getModel, and countTokens to FakeAI", async () => {
42+
const metadata = { taskId: "task-1" } as ApiHandlerCreateMessageMetadata
43+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
44+
const content: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: "Hello" }]
45+
const createMessage = vitest.fn(async function* () {
46+
yield { type: "text" as const, text: "Hello" }
47+
})
48+
const getModel = vitest.fn(() => ({ id: "fake-model", info: modelInfo }))
49+
const countTokens = vitest.fn().mockResolvedValue(7)
50+
const fakeAi: {
51+
id: string
52+
createMessage: typeof createMessage
53+
getModel: typeof getModel
54+
countTokens: typeof countTokens
55+
completePrompt: ReturnType<typeof vitest.fn>
56+
removeFromCache?: () => void
57+
} = {
58+
id: "fake-ai-handler-delegation",
59+
createMessage,
60+
getModel,
61+
countTokens,
62+
completePrompt: vitest.fn().mockResolvedValue("complete"),
63+
}
64+
65+
const handler = new FakeAIHandler({ fakeAi })
66+
const chunks = []
67+
for await (const chunk of handler.createMessage("System", messages, metadata)) {
68+
chunks.push(chunk)
69+
}
70+
71+
expect(chunks).toEqual([{ type: "text", text: "Hello" }])
72+
expect(createMessage).toHaveBeenCalledWith("System", messages, metadata)
73+
expect(handler.getModel()).toEqual({ id: "fake-model", info: modelInfo })
74+
expect(getModel).toHaveBeenCalledTimes(1)
75+
await expect(handler.countTokens(content)).resolves.toBe(7)
76+
expect(countTokens).toHaveBeenCalledWith(content)
77+
fakeAi.removeFromCache?.()
78+
})
79+
})

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

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,63 @@ describe("GeminiHandler", () => {
555555
})
556556
})
557557

558+
describe("completePrompt request options", () => {
559+
it("should pass timeout and baseUrl through httpOptions", async () => {
560+
const handlerWithBaseUrl = new GeminiHandler({
561+
apiKey: "test-key",
562+
apiModelId: GEMINI_MODEL_NAME,
563+
geminiApiKey: "test-key",
564+
googleGeminiBaseUrl: "https://gemini.example.test",
565+
})
566+
handlerWithBaseUrl["client"] = handler["client"] as any
567+
;(handler["client"].models.generateContent as any).mockResolvedValue({ text: "Response" })
568+
569+
const result = await handlerWithBaseUrl.completePrompt("Test prompt", { timeoutMs: 1234 })
570+
571+
expect(result).toBe("Response")
572+
expect(handler["client"].models.generateContent).toHaveBeenCalledWith(
573+
expect.objectContaining({
574+
config: expect.objectContaining({
575+
httpOptions: {
576+
timeout: 1234,
577+
baseUrl: "https://gemini.example.test",
578+
},
579+
}),
580+
}),
581+
)
582+
})
583+
584+
it("should pass abortSignal on config instead of httpOptions", async () => {
585+
const controller = new AbortController()
586+
;(handler["client"].models.generateContent as any).mockResolvedValue({ text: "Response" })
587+
588+
await handler.completePrompt("Test prompt", { abortSignal: controller.signal })
589+
590+
expect(handler["client"].models.generateContent).toHaveBeenCalledWith(
591+
expect.objectContaining({
592+
config: expect.objectContaining({
593+
abortSignal: controller.signal,
594+
httpOptions: undefined,
595+
}),
596+
}),
597+
)
598+
})
599+
600+
it("should omit httpOptions when timeoutMs and baseUrl are not provided", async () => {
601+
;(handler["client"].models.generateContent as any).mockResolvedValue({ text: "Response" })
602+
603+
await handler.completePrompt("Test prompt")
604+
605+
expect(handler["client"].models.generateContent).toHaveBeenCalledWith(
606+
expect.objectContaining({
607+
config: expect.objectContaining({
608+
httpOptions: undefined,
609+
}),
610+
}),
611+
)
612+
})
613+
})
614+
558615
describe("error telemetry", () => {
559616
const mockMessages: Anthropic.Messages.MessageParam[] = [
560617
{

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

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -769,16 +769,17 @@ describe("NativeOllamaHandler", () => {
769769
)
770770
})
771771

772-
it("should accept options param but ignore it (no signal support)", async () => {
772+
it("should use a request-local client when abortSignal is provided", async () => {
773773
mockChat.mockResolvedValue({
774774
message: { content: "Response" },
775775
})
776776

777777
const controller = new AbortController()
778778
await handler.completePrompt("Test prompt", { abortSignal: controller.signal })
779779

780-
// Verify that the call does NOT include any signal-related options
781-
// Ollama implementation only passes the payload, not a second options argument
780+
expect(OllamaMock).toHaveBeenCalledTimes(1)
781+
expect(OllamaMock).toHaveBeenCalledWith({ host: "http://localhost:11434" })
782+
// Ollama implementation only passes the payload, not a second options argument.
782783
expect(mockChat).toHaveBeenCalledWith(
783784
expect.objectContaining({
784785
model: "llama2",
@@ -787,7 +788,6 @@ describe("NativeOllamaHandler", () => {
787788
options: { temperature: 0 },
788789
}),
789790
)
790-
// Verify no second argument was passed (no signal/options forwarded)
791791
expect(mockChat).toHaveBeenCalledTimes(1)
792792
expect(mockChat.mock.calls[0]).toHaveLength(1)
793793
})
@@ -899,6 +899,47 @@ describe("NativeOllamaHandler", () => {
899899
expect(capturedInstanceAbort!).toHaveBeenCalledTimes(1)
900900
})
901901

902+
it("should not create a request-local client or timer for non-positive timeoutMs", async () => {
903+
const setTimeoutSpy = vitest.spyOn(global, "setTimeout")
904+
mockChat.mockResolvedValue({
905+
message: { content: "Response" },
906+
})
907+
908+
await handler.completePrompt("Test prompt", { timeoutMs: 0 })
909+
910+
expect(setTimeoutSpy).not.toHaveBeenCalled()
911+
expect(OllamaMock).toHaveBeenCalledTimes(1)
912+
expect(OllamaMock).toHaveBeenCalledWith({ host: "http://localhost:11434" })
913+
})
914+
915+
it("should remove abort listener and clear timeout when abortSignal fires", async () => {
916+
const controller = new AbortController()
917+
const timeoutHandle = 1 as any
918+
const clearTimeoutSpy = vitest.spyOn(global, "clearTimeout").mockImplementation(() => {})
919+
vitest.spyOn(global, "setTimeout").mockImplementation(() => timeoutHandle)
920+
const removeEventListenerSpy = vitest.spyOn(controller.signal, "removeEventListener")
921+
922+
let resolveChat: (value: { message: { content: string } }) => void = () => {}
923+
mockChat.mockImplementation(
924+
() =>
925+
new Promise((resolve) => {
926+
resolveChat = resolve
927+
}),
928+
)
929+
930+
const promise = handler.completePrompt("Test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
931+
for (let i = 0; i < 10 && mockChat.mock.calls.length === 0; i++) {
932+
await Promise.resolve()
933+
}
934+
935+
controller.abort()
936+
resolveChat({ message: { content: "Response" } })
937+
938+
await expect(promise).resolves.toBe("Response")
939+
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutHandle)
940+
expect(removeEventListenerSpy).toHaveBeenCalledWith("abort", expect.any(Function))
941+
})
942+
902943
it("should clear timeoutId in finally block on success", async () => {
903944
let capturedDelay: number | undefined
904945
const timeoutHandle = 1 as any

0 commit comments

Comments
 (0)