From 91c84e1b1650b638a5434522c5b899872289b303 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Wed, 7 Jan 2026 23:51:02 +0000 Subject: [PATCH] feat: normalize OpenAI base URL to support both formats Fixes #10337 - Add normalizeOpenAiBaseUrl utility to handle both URL formats (with and without /v1 suffix) - Update openai-native.ts to use the URL normalization function - Add UI guidance text explaining supported URL formats - Add translation string for the base URL hint - Add comprehensive tests for URL normalization --- src/api/providers/openai-native.ts | 14 +- .../__tests__/normalizeOpenAiBaseUrl.spec.ts | 131 ++++++++++++++++++ src/utils/normalizeOpenAiBaseUrl.ts | 47 +++++++ .../components/settings/providers/OpenAI.tsx | 5 +- webview-ui/src/i18n/locales/en/settings.json | 1 + 5 files changed, 195 insertions(+), 3 deletions(-) create mode 100644 src/utils/__tests__/normalizeOpenAiBaseUrl.spec.ts create mode 100644 src/utils/normalizeOpenAiBaseUrl.ts diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 58a62497f71..24275541bc2 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -25,6 +25,7 @@ import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" import { isMcpTool } from "../../utils/mcp-name" +import { normalizeOpenAiBaseUrl } from "../../utils/normalizeOpenAiBaseUrl" export type OpenAiNativeModel = ReturnType @@ -68,7 +69,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio this.options.enableResponsesReasoningSummary = true } const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - this.client = new OpenAI({ baseURL: this.options.openAiNativeBaseUrl, apiKey }) + // Normalize the base URL to handle both formats (with and without /v1 suffix). + // The OpenAI SDK expects a base URL that may or may not include /v1. + // We normalize to ensure consistent behavior regardless of user input format. + const normalizedBaseUrl = this.options.openAiNativeBaseUrl + ? normalizeOpenAiBaseUrl(this.options.openAiNativeBaseUrl, "https://api.openai.com") + "/v1" + : undefined + this.client = new OpenAI({ baseURL: normalizedBaseUrl, apiKey }) } private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined { @@ -509,7 +516,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio messages?: Anthropic.Messages.MessageParam[], ): ApiStream { const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com" + // Normalize the base URL to handle both formats (with and without /v1 suffix). + // This ensures consistent URL construction regardless of whether the user provides + // "https://my-host.example" or "https://my-host.example/v1" + const baseUrl = normalizeOpenAiBaseUrl(this.options.openAiNativeBaseUrl, "https://api.openai.com") const url = `${baseUrl}/v1/responses` // Create AbortController for cancellation diff --git a/src/utils/__tests__/normalizeOpenAiBaseUrl.spec.ts b/src/utils/__tests__/normalizeOpenAiBaseUrl.spec.ts new file mode 100644 index 00000000000..68e5a334719 --- /dev/null +++ b/src/utils/__tests__/normalizeOpenAiBaseUrl.spec.ts @@ -0,0 +1,131 @@ +import { normalizeOpenAiBaseUrl } from "../normalizeOpenAiBaseUrl" + +describe("normalizeOpenAiBaseUrl", () => { + const defaultUrl = "https://api.openai.com" + + describe("when baseUrl is empty or undefined", () => { + it("should return the normalized default URL when baseUrl is undefined", () => { + expect(normalizeOpenAiBaseUrl(undefined, defaultUrl)).toBe(defaultUrl) + }) + + it("should return the normalized default URL when baseUrl is empty string", () => { + expect(normalizeOpenAiBaseUrl("", defaultUrl)).toBe(defaultUrl) + }) + + it("should return the normalized default URL when baseUrl is whitespace", () => { + expect(normalizeOpenAiBaseUrl(" ", defaultUrl)).toBe(defaultUrl) + }) + }) + + describe("when baseUrl does not have /v1 suffix", () => { + it("should return the URL as-is for a simple URL", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example", defaultUrl)).toBe("https://my-host.example") + }) + + it("should remove trailing slash", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/", defaultUrl)).toBe("https://my-host.example") + }) + + it("should remove multiple trailing slashes", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example///", defaultUrl)).toBe("https://my-host.example") + }) + + it("should handle URL with port", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example:8080", defaultUrl)).toBe( + "https://my-host.example:8080", + ) + }) + + it("should handle URL with path", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/api", defaultUrl)).toBe( + "https://my-host.example/api", + ) + }) + }) + + describe("when baseUrl has /v1 suffix", () => { + it("should strip /v1 suffix", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/v1", defaultUrl)).toBe("https://my-host.example") + }) + + it("should strip /v1/ suffix with trailing slash", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/v1/", defaultUrl)).toBe("https://my-host.example") + }) + + it("should strip /v1 suffix with multiple trailing slashes", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/v1///", defaultUrl)).toBe("https://my-host.example") + }) + + it("should handle URL with port and /v1 suffix", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example:8080/v1", defaultUrl)).toBe( + "https://my-host.example:8080", + ) + }) + + it("should handle URL with path and /v1 suffix", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/api/v1", defaultUrl)).toBe( + "https://my-host.example/api", + ) + }) + + it("should be case-insensitive for /v1 suffix", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/V1", defaultUrl)).toBe("https://my-host.example") + }) + }) + + describe("when default URL has /v1 suffix", () => { + it("should normalize default URL by stripping /v1", () => { + expect(normalizeOpenAiBaseUrl(undefined, "https://api.openai.com/v1")).toBe("https://api.openai.com") + }) + + it("should normalize default URL by stripping /v1/", () => { + expect(normalizeOpenAiBaseUrl("", "https://api.openai.com/v1/")).toBe("https://api.openai.com") + }) + }) + + describe("edge cases", () => { + it("should handle localhost URLs", () => { + expect(normalizeOpenAiBaseUrl("http://localhost:4000/v1", defaultUrl)).toBe("http://localhost:4000") + }) + + it("should handle IP address URLs", () => { + expect(normalizeOpenAiBaseUrl("http://192.168.1.100:8080/v1", defaultUrl)).toBe("http://192.168.1.100:8080") + }) + + it("should trim whitespace from URL", () => { + expect(normalizeOpenAiBaseUrl(" https://my-host.example/v1 ", defaultUrl)).toBe("https://my-host.example") + }) + + it("should not remove v1 from the middle of the path", () => { + expect(normalizeOpenAiBaseUrl("https://my-host.example/v1/api", defaultUrl)).toBe( + "https://my-host.example/v1/api", + ) + }) + + it("should not remove v1 if it is part of the hostname", () => { + expect(normalizeOpenAiBaseUrl("https://api-v1.example.com", defaultUrl)).toBe("https://api-v1.example.com") + }) + }) + + describe("real-world scenarios", () => { + it("should handle official OpenAI URL without /v1", () => { + expect(normalizeOpenAiBaseUrl("https://api.openai.com", defaultUrl)).toBe("https://api.openai.com") + }) + + it("should handle official OpenAI URL with /v1", () => { + expect(normalizeOpenAiBaseUrl("https://api.openai.com/v1", defaultUrl)).toBe("https://api.openai.com") + }) + + it("should handle Azure OpenAI URLs", () => { + expect(normalizeOpenAiBaseUrl("https://myinstance.openai.azure.com", defaultUrl)).toBe( + "https://myinstance.openai.azure.com", + ) + }) + + it("should handle custom proxy URLs", () => { + expect(normalizeOpenAiBaseUrl("https://proxy.example.com/openai/v1", defaultUrl)).toBe( + "https://proxy.example.com/openai", + ) + }) + }) +}) diff --git a/src/utils/normalizeOpenAiBaseUrl.ts b/src/utils/normalizeOpenAiBaseUrl.ts new file mode 100644 index 00000000000..147d64b2122 --- /dev/null +++ b/src/utils/normalizeOpenAiBaseUrl.ts @@ -0,0 +1,47 @@ +/** + * Normalizes an OpenAI-compatible base URL to ensure consistent URL construction. + * + * This function handles various user-provided base URL formats: + * - With /v1 suffix: "https://api.openai.com/v1" → "https://api.openai.com" + * - Without /v1 suffix: "https://api.openai.com" → "https://api.openai.com" + * - With trailing slash: "https://api.openai.com/" → "https://api.openai.com" + * - With /v1/ suffix: "https://api.openai.com/v1/" → "https://api.openai.com" + * + * The normalized URL can then safely have "/v1/..." appended to it. + * + * @param baseUrl - The user-provided base URL (may or may not include /v1) + * @param defaultUrl - The default URL to use if baseUrl is empty + * @returns The normalized base URL without trailing /v1 or trailing slash + */ +export function normalizeOpenAiBaseUrl(baseUrl: string | undefined, defaultUrl: string): string { + // Use default if no custom URL provided + if (!baseUrl || baseUrl.trim() === "") { + return normalizeUrl(defaultUrl) + } + + return normalizeUrl(baseUrl) +} + +/** + * Internal helper to normalize a URL by removing trailing /v1 and trailing slashes. + */ +function normalizeUrl(url: string): string { + let normalized = url.trim() + + // Remove trailing slashes first + while (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1) + } + + // Remove /v1 suffix if present (case-insensitive for robustness) + if (normalized.toLowerCase().endsWith("/v1")) { + normalized = normalized.slice(0, -3) + } + + // Remove any trailing slashes that might be left after removing /v1 + while (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1) + } + + return normalized +} diff --git a/webview-ui/src/components/settings/providers/OpenAI.tsx b/webview-ui/src/components/settings/providers/OpenAI.tsx index 96fd6c89bed..a7eb0533637 100644 --- a/webview-ui/src/components/settings/providers/OpenAI.tsx +++ b/webview-ui/src/components/settings/providers/OpenAI.tsx @@ -54,9 +54,12 @@ export const OpenAI = ({ apiConfiguration, setApiConfigurationField, selectedMod value={apiConfiguration?.openAiNativeBaseUrl || ""} type="url" onInput={handleInputChange("openAiNativeBaseUrl")} - placeholder="https://api.openai.com/v1" + placeholder="https://api.openai.com" className="w-full mt-1" /> +
+ {t("settings:providers.openAiBaseUrlHint")} +
)}