2
0
Эх сурвалжийг харах

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.
Hannes Rudolph 1 долоо хоног өмнө
parent
commit
23d34154d0

+ 44 - 0
src/api/providers/__tests__/gemini.spec.ts

@@ -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", () => {

+ 23 - 0
src/api/providers/__tests__/openai-native.spec.ts

@@ -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", () => {

+ 1 - 1
src/api/providers/deepseek.ts

@@ -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,
 		})

+ 1 - 1
src/api/providers/gemini.ts

@@ -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,
 		})
 	}

+ 1 - 1
src/api/providers/moonshot.ts

@@ -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,

+ 1 - 1
src/api/providers/openai-native.ts

@@ -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",

+ 1 - 1
src/api/providers/openai.ts

@@ -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)