From bcfb7e4c52a0af7ae05aea89a1b870accf3b2848 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 5 Feb 2026 16:21:17 -0700 Subject: [PATCH] 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. --- 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 8019e3e4360..ceeb553da3e 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 86bb0e9721e..ac50e6b0a1c 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 091f585456c..635759b5191 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 58f58bd1498..b6c25a4d823 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 f7a849cc025..3c732662727 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 abf1a562c74..d7c60c5dafb 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 87589b93960..611b391ce9a 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)