Skip to content

Commit df5be1d

Browse files
James Mtendamemacursoragent
andcommitted
test(zoo-gateway): add ZooGatewayHandler unit tests for codecov patch
Cover constructor auth guard, base URL resolution, streaming, task/mode headers, temperature, cache breakpoints, tool calls, and completePrompt. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4df54a0 commit df5be1d

1 file changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
// npx vitest run src/api/providers/__tests__/zoo-gateway.spec.ts
2+
3+
vitest.mock("vscode", () => ({}))
4+
5+
import OpenAI from "openai"
6+
7+
import { zooGatewayDefaultModelId, ZOO_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types"
8+
9+
import { ZooGatewayHandler } from "../zoo-gateway"
10+
import { ApiHandlerOptions } from "../../../shared/api"
11+
import { Package } from "../../../shared/package"
12+
13+
vitest.mock("openai")
14+
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
15+
vitest.mock("../fetchers/modelCache", () => ({
16+
getModels: vitest.fn().mockImplementation(() => {
17+
return Promise.resolve({
18+
"anthropic/claude-sonnet-4": {
19+
maxTokens: 64000,
20+
contextWindow: 200000,
21+
supportsImages: true,
22+
supportsPromptCache: true,
23+
inputPrice: 3,
24+
outputPrice: 15,
25+
cacheWritesPrice: 3.75,
26+
cacheReadsPrice: 0.3,
27+
description: "Claude Sonnet 4",
28+
},
29+
"anthropic/claude-3.5-haiku": {
30+
maxTokens: 32000,
31+
contextWindow: 200000,
32+
supportsImages: true,
33+
supportsPromptCache: true,
34+
inputPrice: 1,
35+
outputPrice: 5,
36+
cacheWritesPrice: 1.25,
37+
cacheReadsPrice: 0.1,
38+
description: "Claude 3.5 Haiku",
39+
},
40+
})
41+
}),
42+
getModelsFromCache: vitest.fn().mockReturnValue(undefined),
43+
}))
44+
45+
vitest.mock("../../../services/zoo-code-auth", () => ({
46+
getZooCodeBaseUrl: vitest.fn(() => "https://www.zoocode.dev"),
47+
}))
48+
49+
vitest.mock("../../transform/caching/vercel-ai-gateway", () => ({
50+
addCacheBreakpoints: vitest.fn(),
51+
}))
52+
53+
const mockCreate = vitest.fn()
54+
55+
function mockOpenAIClient() {
56+
vitest.mocked(OpenAI).mockImplementation(
57+
() =>
58+
({
59+
chat: {
60+
completions: {
61+
create: mockCreate,
62+
},
63+
},
64+
}) as unknown as OpenAI,
65+
)
66+
}
67+
68+
mockOpenAIClient()
69+
70+
describe("ZooGatewayHandler", () => {
71+
const mockOptions: ApiHandlerOptions = {
72+
zooSessionToken: "zoo_ext_test_token",
73+
zooGatewayModelId: "anthropic/claude-sonnet-4",
74+
}
75+
76+
beforeEach(() => {
77+
vitest.clearAllMocks()
78+
mockCreate.mockClear()
79+
mockOpenAIClient()
80+
})
81+
82+
describe("constructor", () => {
83+
it("requires authentication before constructing the client", () => {
84+
expect(() => new ZooGatewayHandler({})).toThrow(
85+
"Zoo Gateway requires authentication. Please sign in to Zoo Code first.",
86+
)
87+
expect(OpenAI).not.toHaveBeenCalled()
88+
})
89+
90+
it("initializes OpenAI with Zoo enrichment headers and session token", () => {
91+
const handler = new ZooGatewayHandler({
92+
...mockOptions,
93+
zooGatewayBaseUrl: "https://staging.zoocode.dev/api/gateway/v1",
94+
})
95+
96+
expect(handler).toBeInstanceOf(ZooGatewayHandler)
97+
expect(OpenAI).toHaveBeenCalledWith({
98+
baseURL: "https://staging.zoocode.dev/api/gateway/v1",
99+
apiKey: mockOptions.zooSessionToken,
100+
defaultHeaders: expect.objectContaining({
101+
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
102+
"X-Title": "Roo Code",
103+
"X-Zoo-Editor": "vscode",
104+
"X-Zoo-Extension-Version": Package.version,
105+
}),
106+
})
107+
})
108+
109+
it("defaults the gateway base URL from getZooCodeBaseUrl", () => {
110+
new ZooGatewayHandler(mockOptions)
111+
112+
expect(OpenAI).toHaveBeenCalledWith(
113+
expect.objectContaining({
114+
baseURL: "https://www.zoocode.dev/api/gateway/v1",
115+
}),
116+
)
117+
})
118+
})
119+
120+
describe("fetchModel", () => {
121+
it("returns configured model info", async () => {
122+
const handler = new ZooGatewayHandler(mockOptions)
123+
const result = await handler.fetchModel()
124+
125+
expect(result.id).toBe(mockOptions.zooGatewayModelId)
126+
expect(result.info.maxTokens).toBe(64000)
127+
expect(result.info.supportsPromptCache).toBe(true)
128+
})
129+
130+
it("falls back to the default model when none is configured", async () => {
131+
const handler = new ZooGatewayHandler({ zooSessionToken: "zoo_ext_test_token" })
132+
const result = await handler.fetchModel()
133+
134+
expect(result.id).toBe(zooGatewayDefaultModelId)
135+
})
136+
})
137+
138+
describe("createMessage", () => {
139+
beforeEach(() => {
140+
mockCreate.mockImplementation(async () => ({
141+
[Symbol.asyncIterator]: async function* () {
142+
yield {
143+
choices: [{ delta: { content: "Test response" }, index: 0 }],
144+
usage: null,
145+
}
146+
yield {
147+
choices: [{ delta: {}, index: 0 }],
148+
usage: {
149+
prompt_tokens: 10,
150+
completion_tokens: 5,
151+
total_tokens: 15,
152+
cache_creation_input_tokens: 2,
153+
prompt_tokens_details: { cached_tokens: 3 },
154+
cost: 0.005,
155+
},
156+
}
157+
},
158+
}))
159+
})
160+
161+
it("streams text and usage chunks", async () => {
162+
const handler = new ZooGatewayHandler(mockOptions)
163+
const stream = handler.createMessage("You are helpful.", [{ role: "user", content: "Hello" }])
164+
165+
const chunks = []
166+
for await (const chunk of stream) {
167+
chunks.push(chunk)
168+
}
169+
170+
expect(chunks).toEqual([
171+
{ type: "text", text: "Test response" },
172+
{
173+
type: "usage",
174+
inputTokens: 10,
175+
outputTokens: 5,
176+
cacheWriteTokens: 2,
177+
cacheReadTokens: 3,
178+
totalCost: 0.005,
179+
},
180+
])
181+
})
182+
183+
it("forwards task and mode metadata as request headers", async () => {
184+
const handler = new ZooGatewayHandler(mockOptions)
185+
186+
await handler.createMessage("prompt", [], { taskId: "task-123", mode: "code" }).next()
187+
188+
expect(mockCreate).toHaveBeenCalledWith(
189+
expect.any(Object),
190+
expect.objectContaining({
191+
headers: {
192+
"X-Zoo-Task-ID": "task-123",
193+
"X-Zoo-Mode": "code",
194+
},
195+
}),
196+
)
197+
})
198+
199+
it("uses custom temperature when provided", async () => {
200+
const handler = new ZooGatewayHandler({
201+
...mockOptions,
202+
modelTemperature: 0.5,
203+
})
204+
205+
await handler.createMessage("prompt", [{ role: "user", content: "Hi" }]).next()
206+
207+
expect(mockCreate).toHaveBeenCalledWith(
208+
expect.objectContaining({
209+
temperature: 0.5,
210+
}),
211+
expect.any(Object),
212+
)
213+
})
214+
215+
it("uses the default temperature when none is provided", async () => {
216+
const handler = new ZooGatewayHandler(mockOptions)
217+
218+
await handler.createMessage("prompt", [{ role: "user", content: "Hi" }]).next()
219+
220+
expect(mockCreate).toHaveBeenCalledWith(
221+
expect.objectContaining({
222+
temperature: ZOO_GATEWAY_DEFAULT_TEMPERATURE,
223+
}),
224+
expect.any(Object),
225+
)
226+
})
227+
228+
it("adds cache breakpoints for supported models", async () => {
229+
const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway")
230+
const handler = new ZooGatewayHandler({
231+
...mockOptions,
232+
zooGatewayModelId: "anthropic/claude-3.5-haiku",
233+
})
234+
235+
await handler.createMessage("prompt", [{ role: "user", content: "Hi" }]).next()
236+
237+
expect(addCacheBreakpoints).toHaveBeenCalled()
238+
})
239+
240+
it("yields tool_call_partial chunks when streaming tool calls", async () => {
241+
mockCreate.mockImplementation(async () => ({
242+
[Symbol.asyncIterator]: async function* () {
243+
yield {
244+
choices: [
245+
{
246+
delta: {
247+
tool_calls: [
248+
{
249+
index: 0,
250+
id: "call_123",
251+
function: { name: "test_tool", arguments: '{"arg1":' },
252+
},
253+
],
254+
},
255+
index: 0,
256+
},
257+
],
258+
}
259+
},
260+
}))
261+
262+
const handler = new ZooGatewayHandler(mockOptions)
263+
const chunks = []
264+
for await (const chunk of handler.createMessage("prompt", [])) {
265+
chunks.push(chunk)
266+
}
267+
268+
expect(chunks).toEqual([
269+
{
270+
type: "tool_call_partial",
271+
index: 0,
272+
id: "call_123",
273+
name: "test_tool",
274+
arguments: '{"arg1":',
275+
},
276+
])
277+
})
278+
})
279+
280+
describe("completePrompt", () => {
281+
beforeEach(() => {
282+
mockCreate.mockImplementation(async () => ({
283+
choices: [{ message: { role: "assistant", content: "Test completion response" } }],
284+
}))
285+
})
286+
287+
it("returns completion text from the gateway", async () => {
288+
const handler = new ZooGatewayHandler(mockOptions)
289+
290+
const result = await handler.completePrompt("Complete this: Hello")
291+
292+
expect(result).toBe("Test completion response")
293+
expect(mockCreate).toHaveBeenCalledWith(
294+
expect.objectContaining({
295+
model: "anthropic/claude-sonnet-4",
296+
messages: [{ role: "user", content: "Complete this: Hello" }],
297+
stream: false,
298+
temperature: ZOO_GATEWAY_DEFAULT_TEMPERATURE,
299+
max_completion_tokens: 64000,
300+
}),
301+
)
302+
})
303+
304+
it("wraps errors with a Zoo Gateway prefix", async () => {
305+
const handler = new ZooGatewayHandler(mockOptions)
306+
mockCreate.mockImplementation(() => {
307+
throw new Error("upstream failure")
308+
})
309+
310+
await expect(handler.completePrompt("Test")).rejects.toThrow(
311+
"Zoo Gateway completion error: upstream failure",
312+
)
313+
})
314+
315+
it("returns an empty string when the model returns no content", async () => {
316+
const handler = new ZooGatewayHandler(mockOptions)
317+
mockCreate.mockImplementation(async () => ({
318+
choices: [{ message: { role: "assistant", content: null } }],
319+
}))
320+
321+
await expect(handler.completePrompt("Test")).resolves.toBe("")
322+
})
323+
})
324+
})

0 commit comments

Comments
 (0)