From 23d34154d0c5a72f0abee167012f8b9cf3ea6575 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 5 Feb 2026 16:30:59 -0700 Subject: [PATCH] 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. --- src/api/providers/__tests__/gemini.spec.ts | 44 +++++++++++++++++++ .../providers/__tests__/openai-native.spec.ts | 23 ++++++++++ src/api/providers/deepseek.ts | 2 +- src/api/providers/gemini.ts | 2 +- src/api/providers/moonshot.ts | 2 +- src/api/providers/openai-native.ts | 2 +- src/api/providers/openai.ts | 2 +- 7 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index 8019e3e436..ceeb553da3 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/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() + 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", () => { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 86bb0e9721..ac50e6b0a1 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/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).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).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", () => { diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 091f585456..635759b519 100644 --- a/src/api/providers/deepseek.ts +++ b/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, }) diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 58f58bd149..b6c25a4d82 100644 --- a/src/api/providers/gemini.ts +++ b/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, }) } diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index f7a849cc02..3c73266272 100644 --- a/src/api/providers/moonshot.ts +++ b/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, diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index abf1a562c7..d7c60c5daf 100644 --- a/src/api/providers/openai-native.ts +++ b/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", diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 87589b9396..611b391ce9 100644 --- a/src/api/providers/openai.ts +++ b/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)