fix: guard against empty-string baseURL in provider constructors (#11233)

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.
This commit is contained in:
Hannes Rudolph 2026-02-05 16:30:59 -07:00 committed by GitHub
parent 8c6d1ef15d
commit 23d34154d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 72 additions and 5 deletions

View file

@ -23,6 +23,17 @@ vitest.mock("ai", async (importOriginal) => {
}
})
// Mock createGoogleGenerativeAI to capture constructor options
const mockCreateGoogleGenerativeAI = vitest.fn().mockReturnValue(() => ({}))
vitest.mock("@ai-sdk/google", async (importOriginal) => {
const original = await importOriginal<typeof import("@ai-sdk/google")>()
return {
...original,
createGoogleGenerativeAI: (...args: unknown[]) => mockCreateGoogleGenerativeAI(...args),
}
})
import { Anthropic } from "@anthropic-ai/sdk"
import { type ModelInfo, geminiDefaultModelId, ApiProviderError } from "@roo-code/types"
@ -40,6 +51,8 @@ describe("GeminiHandler", () => {
mockCaptureException.mockClear()
mockStreamText.mockClear()
mockGenerateText.mockClear()
mockCreateGoogleGenerativeAI.mockClear()
mockCreateGoogleGenerativeAI.mockReturnValue(() => ({}))
handler = new GeminiHandler({
apiKey: "test-key",
@ -53,6 +66,37 @@ describe("GeminiHandler", () => {
expect(handler["options"].geminiApiKey).toBe("test-key")
expect(handler["options"].apiModelId).toBe(GEMINI_MODEL_NAME)
})
it("should pass undefined baseURL when googleGeminiBaseUrl is empty string", () => {
mockCreateGoogleGenerativeAI.mockClear()
new GeminiHandler({
apiModelId: GEMINI_MODEL_NAME,
geminiApiKey: "test-key",
googleGeminiBaseUrl: "",
})
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
})
it("should pass undefined baseURL when googleGeminiBaseUrl is not provided", () => {
mockCreateGoogleGenerativeAI.mockClear()
new GeminiHandler({
apiModelId: GEMINI_MODEL_NAME,
geminiApiKey: "test-key",
})
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
})
it("should pass custom baseURL when googleGeminiBaseUrl is a valid URL", () => {
mockCreateGoogleGenerativeAI.mockClear()
new GeminiHandler({
apiModelId: GEMINI_MODEL_NAME,
geminiApiKey: "test-key",
googleGeminiBaseUrl: "https://custom-gemini.example.com/v1beta",
})
expect(mockCreateGoogleGenerativeAI).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "https://custom-gemini.example.com/v1beta" }),
)
})
})
describe("createMessage", () => {

View file

@ -11,6 +11,7 @@ vitest.mock("@roo-code/telemetry", () => ({
}))
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiProviderError } from "@roo-code/types"
@ -76,6 +77,28 @@ describe("OpenAiNativeHandler", () => {
})
expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler)
})
it("should pass undefined baseURL when openAiNativeBaseUrl is empty string", () => {
;(OpenAI as unknown as ReturnType<typeof vitest.fn>).mockClear()
new OpenAiNativeHandler({
apiModelId: "gpt-4.1",
openAiNativeApiKey: "test-key",
openAiNativeBaseUrl: "",
})
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined }))
})
it("should pass custom baseURL when openAiNativeBaseUrl is a valid URL", () => {
;(OpenAI as unknown as ReturnType<typeof vitest.fn>).mockClear()
new OpenAiNativeHandler({
apiModelId: "gpt-4.1",
openAiNativeApiKey: "test-key",
openAiNativeBaseUrl: "https://custom-openai.example.com/v1",
})
expect(OpenAI).toHaveBeenCalledWith(
expect.objectContaining({ baseURL: "https://custom-openai.example.com/v1" }),
)
})
})
describe("createMessage", () => {

View file

@ -34,7 +34,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
// Create the DeepSeek provider using AI SDK
this.provider = createDeepSeek({
baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1",
baseURL: options.deepSeekBaseUrl || "https://api.deepseek.com/v1",
apiKey: options.deepSeekApiKey ?? "not-provided",
headers: DEFAULT_HEADERS,
})

View file

@ -42,7 +42,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
// (Vertex authentication happens separately)
this.provider = createGoogleGenerativeAI({
apiKey: this.options.geminiApiKey ?? "not-provided",
baseURL: this.options.googleGeminiBaseUrl,
baseURL: this.options.googleGeminiBaseUrl || undefined,
headers: DEFAULT_HEADERS,
})
}

View file

@ -15,7 +15,7 @@ export class MoonshotHandler extends OpenAICompatibleHandler {
const config: OpenAICompatibleConfig = {
providerName: "moonshot",
baseURL: options.moonshotBaseUrl ?? "https://api.moonshot.ai/v1",
baseURL: options.moonshotBaseUrl || "https://api.moonshot.ai/v1",
apiKey: options.moonshotApiKey ?? "not-provided",
modelId,
modelInfo,

View file

@ -87,7 +87,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Include originator, session_id, and User-Agent headers for API tracking and debugging
const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`
this.client = new OpenAI({
baseURL: this.options.openAiNativeBaseUrl,
baseURL: this.options.openAiNativeBaseUrl || undefined,
apiKey,
defaultHeaders: {
originator: "roo-code",

View file

@ -37,7 +37,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
super()
this.options = options
const baseURL = this.options.openAiBaseUrl ?? "https://api.openai.com/v1"
const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1"
const apiKey = this.options.openAiApiKey ?? "not-provided"
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)