mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
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:
parent
75525817ec
commit
91c84e1b16
5 changed files with 195 additions and 3 deletions
|
|
@ -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
|
||||
|
|
|
|||
131
src/utils/__tests__/normalizeOpenAiBaseUrl.spec.ts
Normal file
131
src/utils/__tests__/normalizeOpenAiBaseUrl.spec.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
47
src/utils/normalizeOpenAiBaseUrl.ts
Normal file
47
src/utils/normalizeOpenAiBaseUrl.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue