Skip to content

Commit a5ab2d4

Browse files
committed
feat(api): wire AbortSignal to SDK providers (vscode-lm, gemini, mistral)
1 parent aca3447 commit a5ab2d4

6 files changed

Lines changed: 386 additions & 2 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// npx vitest run src/api/providers/__tests__/gemini-abort-signal.spec.ts
2+
3+
const mockCaptureException = vitest.fn()
4+
5+
vitest.mock("@roo-code/telemetry", () => ({
6+
TelemetryService: {
7+
instance: {
8+
captureException: (...args: unknown[]) => mockCaptureException(...args),
9+
},
10+
},
11+
}))
12+
13+
import { Anthropic } from "@anthropic-ai/sdk"
14+
import { GeminiHandler } from "../gemini"
15+
16+
const GEMINI_MODEL_NAME = "gemini-2.0-flash-exp"
17+
18+
describe("GeminiHandler abort signal", () => {
19+
let handler: GeminiHandler
20+
21+
beforeEach(() => {
22+
mockCaptureException.mockClear()
23+
24+
const mockGenerateContentStream = vitest.fn()
25+
const mockGenerateContent = vitest.fn()
26+
27+
handler = new GeminiHandler({
28+
apiKey: "test-key",
29+
apiModelId: GEMINI_MODEL_NAME,
30+
geminiApiKey: "test-key",
31+
})
32+
33+
handler["client"] = {
34+
models: {
35+
generateContentStream: mockGenerateContentStream,
36+
generateContent: mockGenerateContent,
37+
},
38+
} as any
39+
})
40+
41+
describe("createMessage", () => {
42+
const mockMessages: Anthropic.Messages.MessageParam[] = [
43+
{ role: "user", content: "Hello" },
44+
{ role: "assistant", content: "Hi there!" },
45+
]
46+
const systemPrompt = "You are a helpful assistant"
47+
48+
it("should forward abortSignal inside config for streaming", async () => {
49+
const controller = new AbortController()
50+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
51+
[Symbol.asyncIterator]: async function* () {
52+
yield { text: "response" }
53+
yield { usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 } }
54+
},
55+
})
56+
57+
const stream = handler.createMessage(systemPrompt, mockMessages, {
58+
taskId: "test",
59+
abortSignal: controller.signal,
60+
})
61+
const chunks = []
62+
for await (const chunk of stream) {
63+
chunks.push(chunk)
64+
}
65+
66+
expect(handler["client"].models.generateContentStream).toHaveBeenCalledWith(
67+
expect.objectContaining({
68+
model: GEMINI_MODEL_NAME,
69+
config: expect.objectContaining({
70+
abortSignal: controller.signal,
71+
}),
72+
}),
73+
)
74+
})
75+
76+
it("should work without abortSignal", async () => {
77+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
78+
[Symbol.asyncIterator]: async function* () {
79+
yield { text: "response" }
80+
yield { usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 } }
81+
},
82+
})
83+
84+
const stream = handler.createMessage(systemPrompt, mockMessages, { taskId: "test" })
85+
const chunks = []
86+
for await (const chunk of stream) {
87+
chunks.push(chunk)
88+
}
89+
90+
expect(chunks.length).toBeGreaterThan(0)
91+
// Without abortSignal, config should not have abortSignal property
92+
const callArgs = (handler["client"].models.generateContentStream as any).mock.calls[0][0]
93+
expect(callArgs.config.abortSignal).toBeUndefined()
94+
})
95+
})
96+
97+
describe("completePrompt", () => {
98+
it("should pass abort signal through to client via httpOptions", async () => {
99+
const controller = new AbortController()
100+
;(handler["client"].models.generateContent as any).mockResolvedValue({ text: "response" })
101+
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
102+
expect(handler["client"].models.generateContent).toHaveBeenCalledWith({
103+
model: GEMINI_MODEL_NAME,
104+
contents: [{ role: "user", parts: [{ text: "test prompt" }] }],
105+
config: {
106+
httpOptions: { signal: controller.signal },
107+
temperature: 1,
108+
},
109+
})
110+
})
111+
112+
it("should work without options (backward compatible)", async () => {
113+
;(handler["client"].models.generateContent as any).mockResolvedValue({ text: "response" })
114+
const result = await handler.completePrompt("test prompt")
115+
expect(result).toBe("response")
116+
})
117+
})
118+
})
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// npx vitest run src/api/providers/__tests__/mistral-abort-signal.spec.ts
2+
3+
const mockCaptureException = vi.hoisted(() => vi.fn())
4+
vi.mock("@roo-code/telemetry", () => ({
5+
TelemetryService: {
6+
instance: {
7+
captureException: mockCaptureException,
8+
},
9+
},
10+
}))
11+
12+
// Mock Mistral client
13+
const mockCreate = vi.fn()
14+
const mockComplete = vi.fn()
15+
vi.mock("@mistralai/mistralai", () => ({
16+
Mistral: vi.fn().mockImplementation(function () {
17+
return {
18+
chat: {
19+
stream: mockCreate.mockImplementation(async (_options) => {
20+
const stream = {
21+
[Symbol.asyncIterator]: async function* () {
22+
yield {
23+
data: {
24+
choices: [{ delta: { content: "Test response" }, index: 0 }],
25+
},
26+
}
27+
},
28+
}
29+
return stream
30+
}),
31+
complete: mockComplete.mockImplementation(async (_options) => ({
32+
choices: [{ message: { content: "Test response" } }],
33+
})),
34+
},
35+
}
36+
}),
37+
}))
38+
39+
import type { Anthropic } from "@anthropic-ai/sdk"
40+
import type OpenAI from "openai"
41+
import { MistralHandler } from "../mistral"
42+
import type { ApiHandlerOptions } from "../../../shared/api"
43+
import type { ApiHandlerCreateMessageMetadata } from "../../index"
44+
45+
describe("MistralHandler abort signal", () => {
46+
let handler: MistralHandler
47+
let mockOptions: ApiHandlerOptions
48+
49+
beforeEach(() => {
50+
mockOptions = {
51+
apiModelId: "codestral-latest",
52+
mistralApiKey: "test-api-key",
53+
includeMaxTokens: true,
54+
modelTemperature: 0,
55+
}
56+
handler = new MistralHandler(mockOptions)
57+
mockCreate.mockClear()
58+
mockComplete.mockClear()
59+
mockCaptureException.mockClear()
60+
})
61+
62+
describe("createMessage streaming", () => {
63+
const systemPrompt = "You are a helpful assistant."
64+
const messages: Anthropic.Messages.MessageParam[] = [
65+
{ role: "user", content: [{ type: "text", text: "Hello!" }] },
66+
]
67+
68+
it("should forward abortSignal to fetchOptions for streaming", async () => {
69+
const controller = new AbortController()
70+
const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", abortSignal: controller.signal }
71+
72+
await handler.createMessage(systemPrompt, messages, metadata).next()
73+
74+
expect(mockCreate).toHaveBeenCalledWith(
75+
expect.objectContaining({
76+
model: mockOptions.apiModelId,
77+
fetchOptions: { signal: controller.signal },
78+
}),
79+
)
80+
})
81+
82+
it("should work without abortSignal", async () => {
83+
const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task" }
84+
85+
await handler.createMessage(systemPrompt, messages, metadata).next()
86+
87+
expect(mockCreate).toHaveBeenCalledWith(
88+
expect.objectContaining({
89+
model: mockOptions.apiModelId,
90+
}),
91+
)
92+
// fetchOptions should be undefined when no abortSignal
93+
const callArgs = mockCreate.mock.calls[0][0]
94+
expect(callArgs.fetchOptions).toBeUndefined()
95+
})
96+
97+
it("should forward abortSignal with tools", async () => {
98+
const controller = new AbortController()
99+
const mockTools: OpenAI.Chat.ChatCompletionTool[] = [
100+
{
101+
type: "function",
102+
function: {
103+
name: "test_tool",
104+
description: "A test tool",
105+
parameters: { type: "object", properties: {} },
106+
},
107+
},
108+
]
109+
const metadata: ApiHandlerCreateMessageMetadata = {
110+
taskId: "test-task",
111+
tools: mockTools,
112+
abortSignal: controller.signal,
113+
}
114+
115+
await handler.createMessage(systemPrompt, messages, metadata).next()
116+
117+
expect(mockCreate).toHaveBeenCalledWith(
118+
expect.objectContaining({
119+
model: mockOptions.apiModelId,
120+
tools: expect.any(Array),
121+
fetchOptions: { signal: controller.signal },
122+
}),
123+
)
124+
})
125+
})
126+
127+
describe("completePrompt non-streaming", () => {
128+
it("should pass abort signal through to client", async () => {
129+
const controller = new AbortController()
130+
mockComplete.mockResolvedValueOnce({
131+
choices: [{ message: { content: "response" } }],
132+
})
133+
await handler.completePrompt("test prompt", { abortSignal: controller.signal })
134+
expect(mockComplete).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), {
135+
fetchOptions: { signal: controller.signal },
136+
})
137+
})
138+
139+
it("should work without options (backward compatible)", async () => {
140+
mockComplete.mockResolvedValueOnce({
141+
choices: [{ message: { content: "response" } }],
142+
})
143+
const result = await handler.completePrompt("test prompt")
144+
expect(result).toBe("response")
145+
expect(mockComplete).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), undefined)
146+
})
147+
148+
it("should pass both signal and timeoutMs", async () => {
149+
const controller = new AbortController()
150+
mockComplete.mockResolvedValueOnce({
151+
choices: [{ message: { content: "response" } }],
152+
})
153+
await handler.completePrompt("test prompt", { abortSignal: controller.signal, timeoutMs: 5000 })
154+
expect(mockComplete).toHaveBeenCalledWith(expect.objectContaining({ model: expect.any(String) }), {
155+
fetchOptions: { signal: controller.signal },
156+
timeoutMs: 5000,
157+
})
158+
})
159+
})
160+
})

src/api/providers/__tests__/vscode-lm.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,87 @@ describe("VsCodeLmHandler", () => {
396396

397397
await expect(handler.createMessage(systemPrompt, messages).next()).rejects.toThrow("API Error")
398398
})
399+
400+
it("should bridge abortSignal to CancellationToken when signal fires", async () => {
401+
const systemPrompt = "You are a helpful assistant"
402+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
403+
404+
mockLanguageModelChat.sendRequest.mockResolvedValueOnce({
405+
stream: (async function* () {
406+
yield {} // eslint disable line require-yield — empty stream for abort test
407+
return
408+
})(),
409+
text: (async function* () {
410+
yield ""
411+
return
412+
})(),
413+
})
414+
415+
const controller = new AbortController()
416+
controller.abort() // Immediately abort before stream starts
417+
418+
await handler
419+
.createMessage(systemPrompt, messages, { taskId: "test", abortSignal: controller.signal })
420+
.next()
421+
422+
// Verify cancel was called on the handler's currentRequestCancellation
423+
const cancellation = handler["currentRequestCancellation"] as any
424+
expect(cancellation).toBeDefined()
425+
expect(cancellation.cancel).toHaveBeenCalled()
426+
})
427+
428+
it("should immediately cancel if abortSignal is already aborted", async () => {
429+
const systemPrompt = "You are a helpful assistant"
430+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
431+
432+
mockLanguageModelChat.sendRequest.mockResolvedValueOnce({
433+
stream: (async function* () {
434+
yield {} // eslint disable line require-yield — empty stream for abort test
435+
return
436+
})(),
437+
text: (async function* () {
438+
yield ""
439+
return
440+
})(),
441+
})
442+
443+
const controller = new AbortController()
444+
controller.abort() // Already aborted before calling createMessage
445+
446+
await handler
447+
.createMessage(systemPrompt, messages, { taskId: "test", abortSignal: controller.signal })
448+
.next()
449+
450+
// Verify cancel was called on the handler's currentRequestCancellation
451+
const cancellation = handler["currentRequestCancellation"] as any
452+
expect(cancellation).toBeDefined()
453+
expect(cancellation.cancel).toHaveBeenCalled()
454+
})
455+
456+
it("should dispose CancellationTokenSource in finally block on success", async () => {
457+
const systemPrompt = "You are a helpful assistant"
458+
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
459+
460+
mockLanguageModelChat.sendRequest.mockResolvedValueOnce({
461+
stream: (async function* () {
462+
yield new vscode.LanguageModelTextPart("done")
463+
return
464+
})(),
465+
text: (async function* () {
466+
yield "done"
467+
return
468+
})(),
469+
})
470+
471+
await handler.createMessage(systemPrompt, messages, { taskId: "test" }).next()
472+
473+
// After completion, the token source is still referenced but will be cleaned up on next request
474+
const cancellationAfter = handler["currentRequestCancellation"] as any
475+
expect(cancellationAfter).toBeDefined()
476+
// dispose happens when ensureCleanState is called (on next request or error)
477+
// For now, just verify the token source exists and dispose method was not yet called
478+
expect(cancellationAfter.dispose).not.toHaveBeenCalled()
479+
})
399480
})
400481

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

src/api/providers/gemini.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
304304
...(tools.length > 0 ? { tools } : {}),
305305
}
306306

307+
// Wire abortSignal into config. Note: per the @google/genai SDK docs,
308+
// `abortSignal` is client-only — Google still charges for server-side
309+
// compute that has already been dispatched before the signal propagates.
310+
if (metadata?.abortSignal) {
311+
config.abortSignal = metadata.abortSignal
312+
}
313+
307314
// Do not pass metadata.allowedFunctionNames to Gemini. Live API testing showed
308315
// that allowedFunctionNames triggers a generic 400 INVALID_ARGUMENT at 26 or more
309316
// names. It can also

0 commit comments

Comments
 (0)