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
This commit is contained in:
Roo Code 2026-01-07 23:51:02 +00:00
parent 75525817ec
commit 91c84e1b16
5 changed files with 195 additions and 3 deletions

View file

@ -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<OpenAiNativeHandler["getModel"]>
@ -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

View file

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

View file

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

View file

@ -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"
/>
<div className="text-sm text-vscode-descriptionForeground -mt-1">
{t("settings:providers.openAiBaseUrlHint")}
</div>
</>
)}
<VSCodeTextField

View file

@ -285,6 +285,7 @@
"getVercelAiGatewayApiKey": "Get Vercel AI Gateway API Key",
"apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage",
"useCustomBaseUrl": "Use custom base URL",
"openAiBaseUrlHint": "Both formats are supported: https://my-host.example or https://my-host.example/v1",
"useReasoning": "Enable reasoning",
"useHostHeader": "Use custom Host header",
"useLegacyFormat": "Use legacy OpenAI API format",