Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/api/providers/openai-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]>

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions src/utils/__tests__/normalizeOpenAiBaseUrl.spec.ts
Original file line number Diff line number Diff line change
@@ -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",
)
})
})
})
47 changes: 47 additions & 0 deletions src/utils/normalizeOpenAiBaseUrl.ts
Original file line number Diff line number Diff line change
@@ -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
}
5 changes: 4 additions & 1 deletion webview-ui/src/components/settings/providers/OpenAI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading