Skip to content

Commit bcfb7e4

Browse files
committed
fix: guard against empty-string baseURL in provider constructors
When the 'custom base URL' checkbox is unchecked in the UI, the setting is set to '' (empty string). Providers that passed this directly to their SDK constructors caused 'Failed to parse URL' errors because the SDK treated '' as a valid but broken base URL override. - gemini.ts: use || undefined (was passing raw option) - openai-native.ts: use || undefined (was passing raw option) - openai.ts: change ?? to || for fallback default - deepseek.ts: change ?? to || for fallback default - moonshot.ts: change ?? to || for fallback default Adds test coverage for Gemini and OpenAI Native constructors verifying empty-string baseURL is coerced to undefined.
1 parent 2d5e633 commit bcfb7e4

7 files changed

Lines changed: 72 additions & 5 deletions

File tree

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,17 @@ vitest.mock("ai", async (importOriginal) => {
2323
}
2424
})
2525

26+
// Mock createGoogleGenerativeAI to capture constructor options
27+
const mockCreateGoogleGenerativeAI = vitest.fn().mockReturnValue(() => ({}))
28+
29+
vitest.mock("@ai-sdk/google", async (importOriginal) => {
30+
const original = await importOriginal<typeof import("@ai-sdk/google")>()
31+
return {
32+
...original,
33+
createGoogleGenerativeAI: (...args: unknown[]) => mockCreateGoogleGenerativeAI(...args),
34+
}
35+
})
36+
2637
import { Anthropic } from "@anthropic-ai/sdk"
2738

2839
import { type ModelInfo, geminiDefaultModelId, ApiProviderError } from "@roo-code/types"
@@ -40,6 +51,8 @@ describe("GeminiHandler", () => {
4051
mockCaptureException.mockClear()
4152
mockStreamText.mockClear()
4253
mockGenerateText.mockClear()
54+
mockCreateGoogleGenerativeAI.mockClear()
55+
mockCreateGoogleGenerativeAI.mockReturnValue(() => ({}))
4356

4457
handler = new GeminiHandler({
4558
apiKey: "test-key",
@@ -53,6 +66,37 @@ describe("GeminiHandler", () => {
5366
expect(handler["options"].geminiApiKey).toBe("test-key")
5467
expect(handler["options"].apiModelId).toBe(GEMINI_MODEL_NAME)
5568
})
69+
70+
it("should pass undefined baseURL when googleGeminiBaseUrl is empty string", () => {
71+
mockCreateGoogleGenerativeAI.mockClear()
72+
new GeminiHandler({
73+
apiModelId: GEMINI_MODEL_NAME,
74+
geminiApiKey: "test-key",
75+
googleGeminiBaseUrl: "",
76+
})
77+
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
78+
})
79+
80+
it("should pass undefined baseURL when googleGeminiBaseUrl is not provided", () => {
81+
mockCreateGoogleGenerativeAI.mockClear()
82+
new GeminiHandler({
83+
apiModelId: GEMINI_MODEL_NAME,
84+
geminiApiKey: "test-key",
85+
})
86+
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
87+
})
88+
89+
it("should pass custom baseURL when googleGeminiBaseUrl is a valid URL", () => {
90+
mockCreateGoogleGenerativeAI.mockClear()
91+
new GeminiHandler({
92+
apiModelId: GEMINI_MODEL_NAME,
93+
geminiApiKey: "test-key",
94+
googleGeminiBaseUrl: "https://custom-gemini.example.com/v1beta",
95+
})
96+
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(
97+
expect.objectContaining({ baseURL: "https://custom-gemini.example.com/v1beta" }),
98+
)
99+
})
56100
})
57101

58102
describe("createMessage", () => {

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ vitest.mock("@roo-code/telemetry", () => ({
1111
}))
1212

1313
import { Anthropic } from "@anthropic-ai/sdk"
14+
import OpenAI from "openai"
1415

1516
import { ApiProviderError } from "@roo-code/types"
1617

@@ -76,6 +77,28 @@ describe("OpenAiNativeHandler", () => {
7677
})
7778
expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler)
7879
})
80+
81+
it("should pass undefined baseURL when openAiNativeBaseUrl is empty string", () => {
82+
;(OpenAI as unknown as ReturnType<typeof vitest.fn>).mockClear()
83+
new OpenAiNativeHandler({
84+
apiModelId: "gpt-4.1",
85+
openAiNativeApiKey: "test-key",
86+
openAiNativeBaseUrl: "",
87+
})
88+
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
89+
})
90+
91+
it("should pass custom baseURL when openAiNativeBaseUrl is a valid URL", () => {
92+
;(OpenAI as unknown as ReturnType<typeof vitest.fn>).mockClear()
93+
new OpenAiNativeHandler({
94+
apiModelId: "gpt-4.1",
95+
openAiNativeApiKey: "test-key",
96+
openAiNativeBaseUrl: "https://custom-openai.example.com/v1",
97+
})
98+
expect(OpenAI).toHaveBeenCalledWith(
99+
expect.objectContaining({ baseURL: "https://custom-openai.example.com/v1" }),
100+
)
101+
})
79102
})
80103

81104
describe("createMessage", () => {

src/api/providers/deepseek.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
3434

3535
// Create the DeepSeek provider using AI SDK
3636
this.provider = createDeepSeek({
37-
baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1",
37+
baseURL: options.deepSeekBaseUrl || "https://api.deepseek.com/v1",
3838
apiKey: options.deepSeekApiKey ?? "not-provided",
3939
headers: DEFAULT_HEADERS,
4040
})

src/api/providers/gemini.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
4242
// (Vertex authentication happens separately)
4343
this.provider = createGoogleGenerativeAI({
4444
apiKey: this.options.geminiApiKey ?? "not-provided",
45-
baseURL: this.options.googleGeminiBaseUrl,
45+
baseURL: this.options.googleGeminiBaseUrl || undefined,
4646
headers: DEFAULT_HEADERS,
4747
})
4848
}

src/api/providers/moonshot.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class MoonshotHandler extends OpenAICompatibleHandler {
1515

1616
const config: OpenAICompatibleConfig = {
1717
providerName: "moonshot",
18-
baseURL: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1",
18+
baseURL: options.moonshotBaseUrl || "https://api.moonshot.ai/v1",
1919
apiKey: options.moonshotApiKey ?? "not-provided",
2020
modelId,
2121
modelInfo,

src/api/providers/openai-native.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
8787
// Include originator, session_id, and User-Agent headers for API tracking and debugging
8888
const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`
8989
this.client = new OpenAI({
90-
baseURL: this.options.openAiNativeBaseUrl,
90+
baseURL: this.options.openAiNativeBaseUrl || undefined,
9191
apiKey,
9292
defaultHeaders: {
9393
originator: "roo-code",

src/api/providers/openai.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
3737
super()
3838
this.options = options
3939

40-
const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
40+
const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1"
4141
const apiKey = this.options.openAiApiKey ?? "not-provided"
4242
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
4343
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)

0 commit comments

Comments
 (0)