Skip to content

Commit c674eec

Browse files
committed
fix: resolve CI failures in provider tests
- requesty.spec.ts & sambanova.spec.ts: Remove extra closing brace causing parse errors (compile failure) - All abortSignal tests: Fix async generator iteration pattern - bare await on createMessage() returns the generator without executing its body; use for-await loop to trigger mockCreate calls before assertions Fixes compile error + platform-unit-test failures in PR #434
1 parent 97ee48a commit c674eec

31 files changed

Lines changed: 937 additions & 72 deletions

src/api/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,12 @@ export interface ApiHandlerCreateMessageMetadata {
9090
* Only applies to providers that support function calling restrictions (e.g., Gemini).
9191
*/
9292
allowedFunctionNames?: string[]
93+
/**
94+
* Abort signal for cancelling the HTTP request mid-stream.
95+
* Passed through to AI SDK's streamText() so the underlying HTTP request is aborted
96+
* when the user clicks stop, preventing wasted API tokens/compute on the provider side.
97+
*/
98+
abortSignal?: AbortSignal
9399
}
94100

95101
export interface ApiHandler {

src/api/providers/__tests__/base-openai-compatible-provider.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ describe("BaseOpenAiCompatibleProvider", () => {
354354
stream: true,
355355
stream_options: { include_usage: true },
356356
}),
357-
undefined,
357+
expect.any(Object),
358358
)
359359
})
360360

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,48 @@ describe("DeepSeekHandler", () => {
637637
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
638638
expect(toolCallChunks.length).toBeGreaterThan(0)
639639
expect(toolCallChunks[0].name).toBe("get_weather")
640+
641+
describe("abortSignal support", () => {
642+
it("should pass abortSignal to chat.completions.create when provided in metadata", async () => {
643+
const handler = new DeepSeekHandler({ ...mockOptions, apiKey: "test-key" })
644+
const systemPrompt = "You are a helpful assistant."
645+
const messages: Anthropic.Messages.MessageParam[] = [
646+
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
647+
]
648+
649+
const controller = new AbortController()
650+
const mockAbortSignal = controller.signal
651+
652+
await handler.createMessage(systemPrompt, messages, {
653+
taskId: "test",
654+
abortSignal: mockAbortSignal,
655+
})
656+
for await (const _chunk of handler.createMessage(systemPrompt, messages)) {
657+
break
658+
}
659+
660+
expect(mockCreate).toHaveBeenCalled()
661+
const callArgs = mockCreate.mock.calls[0][0]
662+
expect(callArgs.signal).toBe(mockAbortSignal)
663+
})
664+
665+
it("should not include signal when abortSignal is not provided", async () => {
666+
const handler = new DeepSeekHandler({ ...mockOptions, apiKey: "test-key" })
667+
const systemPrompt = "You are a helpful assistant."
668+
const messages: Anthropic.Messages.MessageParam[] = [
669+
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
670+
]
671+
672+
await handler.createMessage(systemPrompt, messages)
673+
for await (const _chunk of handler.createMessage(systemPrompt, messages)) {
674+
break
675+
}
676+
677+
expect(mockCreate).toHaveBeenCalled()
678+
const callArgs = mockCreate.mock.calls[0][0]
679+
expect(callArgs.signal).toBeUndefined()
680+
})
681+
})
640682
})
641683
})
642684
})

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

Lines changed: 44 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -95,25 +95,46 @@ describe("FireworksHandler", () => {
9595
})
9696

9797
it.each([
98-
{ modelId: "accounts/fireworks/models/glm-5p1" as const, contextWindow: 202752, inputPrice: 1.4, outputPrice: 4.4, cacheReadsPrice: 0.26 },
99-
{ modelId: "accounts/fireworks/models/kimi-k2p6" as const, contextWindow: 262144, inputPrice: 0.95, outputPrice: 4.0, cacheReadsPrice: 0.16 },
100-
{ modelId: "accounts/fireworks/models/deepseek-v4-pro" as const, contextWindow: 1048576, inputPrice: 1.74, outputPrice: 3.48, cacheReadsPrice: 0.14 },
101-
])("should expose newly added model $modelId", ({ modelId, contextWindow, inputPrice, outputPrice, cacheReadsPrice }) => {
102-
expect(fireworksModels[modelId]).toBeDefined()
103-
const info = fireworksModels[modelId]
104-
expect(info.maxTokens).toBeGreaterThan(0)
105-
expect(info.contextWindow).toBe(contextWindow)
106-
expect(info.inputPrice).toBe(inputPrice)
107-
expect(info.outputPrice).toBe(outputPrice)
108-
expect(info.cacheReadsPrice).toBe(cacheReadsPrice)
109-
expect(info.description).toBeTruthy()
110-
111-
const handlerWithModel = new FireworksHandler({
112-
apiModelId: modelId,
113-
fireworksApiKey: "test-fireworks-api-key",
114-
})
115-
expect(handlerWithModel.getModel().id).toBe(modelId)
116-
})
98+
{
99+
modelId: "accounts/fireworks/models/glm-5p1" as const,
100+
contextWindow: 202752,
101+
inputPrice: 1.4,
102+
outputPrice: 4.4,
103+
cacheReadsPrice: 0.26,
104+
},
105+
{
106+
modelId: "accounts/fireworks/models/kimi-k2p6" as const,
107+
contextWindow: 262144,
108+
inputPrice: 0.95,
109+
outputPrice: 4.0,
110+
cacheReadsPrice: 0.16,
111+
},
112+
{
113+
modelId: "accounts/fireworks/models/deepseek-v4-pro" as const,
114+
contextWindow: 1048576,
115+
inputPrice: 1.74,
116+
outputPrice: 3.48,
117+
cacheReadsPrice: 0.14,
118+
},
119+
])(
120+
"should expose newly added model $modelId",
121+
({ modelId, contextWindow, inputPrice, outputPrice, cacheReadsPrice }) => {
122+
expect(fireworksModels[modelId]).toBeDefined()
123+
const info = fireworksModels[modelId]
124+
expect(info.maxTokens).toBeGreaterThan(0)
125+
expect(info.contextWindow).toBe(contextWindow)
126+
expect(info.inputPrice).toBe(inputPrice)
127+
expect(info.outputPrice).toBe(outputPrice)
128+
expect(info.cacheReadsPrice).toBe(cacheReadsPrice)
129+
expect(info.description).toBeTruthy()
130+
131+
const handlerWithModel = new FireworksHandler({
132+
apiModelId: modelId,
133+
fireworksApiKey: "test-fireworks-api-key",
134+
})
135+
expect(handlerWithModel.getModel().id).toBe(modelId)
136+
},
137+
)
117138

118139
it("should return Kimi K2 Instruct model with correct configuration", () => {
119140
const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct"
@@ -465,7 +486,7 @@ describe("FireworksHandler", () => {
465486
stream: true,
466487
stream_options: { include_usage: true },
467488
}),
468-
undefined,
489+
expect.any(Object),
469490
)
470491
})
471492

@@ -491,7 +512,7 @@ describe("FireworksHandler", () => {
491512
expect.objectContaining({
492513
temperature: 0.5,
493514
}),
494-
undefined,
515+
expect.any(Object),
495516
)
496517
})
497518

@@ -518,7 +539,7 @@ describe("FireworksHandler", () => {
518539
expect.objectContaining({
519540
temperature: 1.0,
520541
}),
521-
undefined,
542+
expect.any(Object),
522543
)
523544
})
524545

@@ -546,7 +567,7 @@ describe("FireworksHandler", () => {
546567
expect.objectContaining({
547568
temperature: 0.7,
548569
}),
549-
undefined,
570+
expect.any(Object),
550571
)
551572
})
552573

src/api/providers/__tests__/lmstudio-native-tools.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ describe("LmStudioHandler Native Tools", () => {
8181
}),
8282
]),
8383
}),
84+
expect.any(Object),
8485
)
8586
// parallel_tool_calls should be true by default when not explicitly set
8687
const callArgs = mockCreate.mock.calls[0][0]
@@ -107,6 +108,7 @@ describe("LmStudioHandler Native Tools", () => {
107108
expect.objectContaining({
108109
tool_choice: "auto",
109110
}),
111+
expect.any(Object),
110112
)
111113
})
112114

@@ -219,6 +221,7 @@ describe("LmStudioHandler Native Tools", () => {
219221
expect.objectContaining({
220222
parallel_tool_calls: true,
221223
}),
224+
expect.any(Object),
222225
)
223226
})
224227

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ describe("MimoHandler", () => {
374374
expect.objectContaining({
375375
extra_body: { thinking: { type: "enabled" } },
376376
}),
377+
expect.any(Object),
377378
)
378379
})
379380

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
// Tests for OpenAICompatibleHandler's abortSignal passing to streamText()
2+
// Verifies that when createMessage() is called with metadata containing an abortSignal,
3+
// the signal is passed through to AI SDK's streamText() so HTTP requests can be aborted.
4+
5+
const { mockStreamText } = vi.hoisted(() => ({
6+
mockStreamText: vi.fn(),
7+
}))
8+
9+
vi.mock("ai", async (importOriginal) => {
10+
const actual = await importOriginal<typeof import("ai")>()
11+
return {
12+
...actual,
13+
streamText: mockStreamText,
14+
}
15+
})
16+
17+
vi.mock("@ai-sdk/openai-compatible", () => ({
18+
createOpenAICompatible: vi.fn(() => {
19+
return vi.fn(() => ({
20+
modelId: "test-model",
21+
provider: "test-provider",
22+
}))
23+
}),
24+
}))
25+
26+
import type { Anthropic } from "@anthropic-ai/sdk"
27+
28+
import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "../openai-compatible"
29+
import type { ApiHandlerOptions } from "../../../shared/api"
30+
import type { ModelInfo } from "@roo-code/types"
31+
32+
// Concrete test subclass of the abstract OpenAICompatibleHandler
33+
class TestOpenAiCompatibleHandler extends OpenAICompatibleHandler {
34+
constructor(options: ApiHandlerOptions, config: OpenAICompatibleConfig) {
35+
super(options, config)
36+
}
37+
38+
override getModel(): { id: string; info: ModelInfo } {
39+
return { id: this.config.modelId, info: this.config.modelInfo }
40+
}
41+
}
42+
43+
describe("OpenAICompatibleHandler abort signal", () => {
44+
let handler: TestOpenAiCompatibleHandler
45+
const mockOptions: ApiHandlerOptions = {}
46+
const config: OpenAICompatibleConfig = {
47+
providerName: "test-provider",
48+
baseURL: "https://api.test.com/v1",
49+
apiKey: "test-key",
50+
modelId: "test-model",
51+
modelInfo: { maxTokens: 8192, contextWindow: 128000, supportsImages: false, supportsPromptCache: true },
52+
}
53+
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
handler = new TestOpenAiCompatibleHandler(mockOptions, config)
57+
})
58+
59+
describe("createMessage abortSignal passing", () => {
60+
const systemPrompt = "You are a helpful assistant."
61+
const messages: Anthropic.Messages.MessageParam[] = [
62+
{ role: "user", content: [{ type: "text" as const, text: "Hello!" }] },
63+
]
64+
65+
it("should pass abortSignal to streamText when provided in metadata", async () => {
66+
const controller = new AbortController()
67+
const mockAbortSignal = controller.signal
68+
69+
async function* mockFullStream() {
70+
yield { type: "text-delta", text: "Test response" }
71+
}
72+
73+
mockStreamText.mockReturnValue({
74+
fullStream: mockFullStream(),
75+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
76+
})
77+
78+
await handler
79+
.createMessage(systemPrompt, messages, {
80+
taskId: "test-task",
81+
abortSignal: mockAbortSignal,
82+
})
83+
.next()
84+
85+
expect(mockStreamText).toHaveBeenCalledWith(
86+
expect.objectContaining({
87+
abortSignal: mockAbortSignal,
88+
}),
89+
)
90+
})
91+
92+
it("should pass undefined signal to streamText when abortSignal is not provided", async () => {
93+
async function* mockFullStream() {
94+
yield { type: "text-delta", text: "Test response" }
95+
}
96+
97+
mockStreamText.mockReturnValue({
98+
fullStream: mockFullStream(),
99+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
100+
})
101+
102+
await handler
103+
.createMessage(systemPrompt, messages, {
104+
taskId: "test-task",
105+
})
106+
.next()
107+
108+
expect(mockStreamText).toHaveBeenCalledWith(
109+
expect.objectContaining({
110+
abortSignal: undefined,
111+
}),
112+
)
113+
})
114+
115+
it("should pass signal to streamText when metadata is undefined", async () => {
116+
async function* mockFullStream() {
117+
yield { type: "text-delta", text: "Test response" }
118+
}
119+
120+
mockStreamText.mockReturnValue({
121+
fullStream: mockFullStream(),
122+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
123+
})
124+
125+
await handler.createMessage(systemPrompt, messages).next()
126+
127+
expect(mockStreamText).toHaveBeenCalledWith(
128+
expect.objectContaining({
129+
abortSignal: undefined,
130+
}),
131+
)
132+
})
133+
134+
it("should pass the correct signal when it fires during streaming", async () => {
135+
const controller = new AbortController()
136+
const mockAbortSignal = controller.signal
137+
138+
let capturedOptions: any = null
139+
140+
mockStreamText.mockImplementation((options) => {
141+
capturedOptions = options
142+
return {
143+
fullStream: (async function* () {
144+
yield { type: "text-delta", text: "Partial" }
145+
})(),
146+
usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }),
147+
}
148+
})
149+
150+
const stream = handler.createMessage(systemPrompt, messages, {
151+
taskId: "test-task",
152+
abortSignal: mockAbortSignal,
153+
})
154+
155+
await stream.next()
156+
// Verify the signal was captured before aborting
157+
expect(capturedOptions).toBeDefined()
158+
expect(capturedOptions.abortSignal).toBe(mockAbortSignal)
159+
160+
// Now abort - this should cause streamText to receive an aborted signal
161+
controller.abort()
162+
expect(controller.signal.aborted).toBe(true)
163+
})
164+
165+
it("should pass all other request options alongside the signal", async () => {
166+
const controller = new AbortController()
167+
168+
let capturedOptions: any = null
169+
170+
mockStreamText.mockImplementation((options) => {
171+
capturedOptions = options
172+
return {
173+
fullStream: (async function* () {
174+
yield { type: "text-delta", text: "Test" }
175+
})(),
176+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
177+
}
178+
})
179+
180+
await handler
181+
.createMessage(systemPrompt, messages, {
182+
taskId: "test-task",
183+
abortSignal: controller.signal,
184+
})
185+
.next()
186+
187+
expect(capturedOptions).toHaveProperty("model")
188+
expect(capturedOptions).toHaveProperty("system", systemPrompt)
189+
expect(capturedOptions).toHaveProperty("messages")
190+
expect(capturedOptions).toHaveProperty("abortSignal", controller.signal)
191+
})
192+
})
193+
})

0 commit comments

Comments
 (0)