diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts
index e8146a999a..4844ffedc3 100644
--- a/src/api/providers/__tests__/openai.spec.ts
+++ b/src/api/providers/__tests__/openai.spec.ts
@@ -3,7 +3,7 @@
import { OpenAiHandler, getOpenAiModels } from "../openai"
import { ApiHandlerOptions } from "../../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
-import OpenAI from "openai"
+import OpenAI, { AzureOpenAI } from "openai"
import { openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
import { Package } from "../../../shared/package"
import { makeApiHandlerOptions } from "../../../test-utils/api"
@@ -20,6 +20,7 @@ const mockCreate = vitest.fn()
vitest.mock("openai", () => {
const mockConstructor = vitest.fn()
+ const mockAzureConstructor = vitest.fn()
return {
__esModule: true,
default: mockConstructor.mockImplementation(function () {
@@ -74,6 +75,7 @@ vitest.mock("openai", () => {
},
}
}),
+ AzureOpenAI: mockAzureConstructor,
}
})
@@ -126,6 +128,31 @@ describe("OpenAiHandler", () => {
timeout: MOCK_TIMEOUT_MS,
})
})
+
+ it.each([
+ ["https://resource.openai.azure.com", "https://resource.openai.azure.com/openai"],
+ ["https://resource.openai.azure.com/", "https://resource.openai.azure.com/openai"],
+ ["https://resource.openai.azure.com/openai", "https://resource.openai.azure.com/openai"],
+ ["https://resource.openai.azure.com/openai/", "https://resource.openai.azure.com/openai"],
+ ])("normalizes Azure OpenAI base URL %s", (openAiBaseUrl, expectedBaseUrl) => {
+ new OpenAiHandler({ ...mockOptions, openAiBaseUrl })
+
+ expect(vi.mocked(AzureOpenAI)).toHaveBeenLastCalledWith(
+ expect.objectContaining({ baseURL: expectedBaseUrl }),
+ )
+ })
+
+ it("normalizes reverse-proxy URLs when Azure mode is enabled", () => {
+ new OpenAiHandler({
+ ...mockOptions,
+ openAiBaseUrl: "https://models.example.com/azure/",
+ openAiUseAzure: true,
+ })
+
+ expect(vi.mocked(AzureOpenAI)).toHaveBeenLastCalledWith(
+ expect.objectContaining({ baseURL: "https://models.example.com/azure/openai" }),
+ )
+ })
})
describe("createMessage", () => {
diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts
index 9545068794..90f453c5d8 100644
--- a/src/api/providers/openai.ts
+++ b/src/api/providers/openai.ts
@@ -60,8 +60,9 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
} else if (isAzureOpenAi) {
// Azure API shape slightly differs from the core API shape:
// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
+ const azureBaseURL = `${baseURL.replace(/\/openai\/?$/, "").replace(/\/$/, "")}/openai`
this.client = new AzureOpenAI({
- baseURL,
+ baseURL: azureBaseURL,
apiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: headers,
diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx
index 8b11c128c7..1d636e3e76 100644
--- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx
+++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx
@@ -42,6 +42,18 @@ export const OpenAICompatible = ({
simplifySettings,
}: OpenAICompatibleProps) => {
const { t } = useAppTranslation()
+ const isAzureOpenAi = (() => {
+ if (apiConfiguration?.openAiUseAzure) {
+ return true
+ }
+
+ try {
+ const host = new URL(apiConfiguration?.openAiBaseUrl || "").host
+ return host === "azure.com" || host.endsWith(".azure.com")
+ } catch {
+ return false
+ }
+ })()
const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)
@@ -129,7 +141,11 @@ export const OpenAICompatible = ({
value={apiConfiguration?.openAiBaseUrl || ""}
type="url"
onInput={handleInputChange("openAiBaseUrl")}
- placeholder={t("settings:placeholders.baseUrl")}
+ placeholder={
+ isAzureOpenAi
+ ? t("settings:providers.azureOpenAiBaseUrlPlaceholder")
+ : t("settings:placeholders.baseUrl")
+ }
className="w-full">
@@ -147,12 +163,18 @@ export const OpenAICompatible = ({
defaultModelId="gpt-4o"
models={openAiModels}
modelIdKey="openAiModelId"
+ label={isAzureOpenAi ? t("settings:providers.azureOpenAiDeploymentName") : undefined}
serviceName="OpenAI"
serviceUrl="https://platform.openai.com"
organizationAllowList={organizationAllowList}
errorMessage={modelValidationError}
simplifySettings={simplifySettings}
/>
+ {isAzureOpenAi && (
+
+ {t("settings:providers.azureOpenAiDeploymentNameDescription")}
+
+ )}
({
}))
// Mock other components
+const { mockModelPicker } = vi.hoisted(() => ({ mockModelPicker: vi.fn() }))
+
vi.mock("../../ModelPicker", () => ({
- ModelPicker: () => Model Picker
,
+ ModelPicker: (props: any) => {
+ mockModelPicker(props)
+ return Model Picker
+ },
}))
vi.mock("../../R1FormatSetting", () => ({
@@ -144,6 +149,43 @@ describe("OpenAICompatible Component - includeMaxTokens checkbox", () => {
})
})
+ describe("Azure OpenAI guidance", () => {
+ it.each([
+ { openAiBaseUrl: "https://resource.openai.azure.com/" },
+ { openAiBaseUrl: "https://models.example.com", openAiUseAzure: true },
+ ])("shows Azure-specific endpoint and deployment guidance", (apiConfiguration) => {
+ render(
+ ,
+ )
+
+ expect(screen.getByPlaceholderText("settings:providers.azureOpenAiBaseUrlPlaceholder")).toBeInTheDocument()
+ expect(mockModelPicker).toHaveBeenLastCalledWith(
+ expect.objectContaining({ label: "settings:providers.azureOpenAiDeploymentName" }),
+ )
+ expect(screen.getByText("settings:providers.azureOpenAiDeploymentNameDescription")).toBeInTheDocument()
+ })
+
+ it("keeps generic OpenAI-compatible guidance for non-Azure endpoints", () => {
+ render(
+ ,
+ )
+
+ expect(screen.getByPlaceholderText("settings:placeholders.baseUrl")).toBeInTheDocument()
+ expect(mockModelPicker).toHaveBeenLastCalledWith(expect.objectContaining({ label: undefined }))
+ expect(
+ screen.queryByText("settings:providers.azureOpenAiDeploymentNameDescription"),
+ ).not.toBeInTheDocument()
+ })
+ })
+
describe("Initial State", () => {
it("should show checkbox as checked when includeMaxTokens is true", () => {
const apiConfiguration: Partial = {
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx
new file mode 100644
index 0000000000..1c2d1a5389
--- /dev/null
+++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.fixture.tsx
@@ -0,0 +1,68 @@
+/* v8 ignore file -- Playwright component fixture is covered by the visual test. */
+import React from "react"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+
+import { type ProviderSettings } from "@roo-code/types"
+
+import { TranslationContext as AppTranslationContext } from "@/i18n/TranslationContext"
+import { TranslationContext as PlaywrightTranslationContext } from "@src/i18n/TranslationContext"
+import { TooltipProvider } from "@src/components/ui/tooltip"
+import { OpenAICompatible } from "../OpenAICompatible"
+
+const translations: Record = {
+ "settings:providers.openAiBaseUrl": "Base URL",
+ "settings:providers.azureOpenAiBaseUrlPlaceholder": "https://.openai.azure.com/openai",
+ "settings:providers.apiKey": "API Key",
+ "settings:placeholders.apiKey": "Enter API key",
+ "settings:providers.azureOpenAiDeploymentName": "Azure deployment name",
+ "settings:providers.azureOpenAiDeploymentNameDescription":
+ "Enter the deployment name from Azure AI Studio, not the underlying model name.",
+ "settings:modelPicker.simplifiedExplanation": "You can adjust detailed model settings later.",
+ "settings:modelInfo.enableR1Format": "Enable R1 model parameters",
+ "settings:modelInfo.enableR1FormatTips": "Enable this only for R1-compatible models.",
+ "settings:modelInfo.enableStreaming": "Enable streaming",
+ "settings:includeMaxOutputTokens": "Include max output tokens",
+ "settings:includeMaxOutputTokensDescription": "Send the configured maximum output token limit.",
+ "settings:modelInfo.useAzure": "Use Azure",
+ "settings:modelInfo.azureApiVersion": "Set Azure API version",
+}
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ },
+})
+
+const apiConfiguration: ProviderSettings = {
+ apiProvider: "openai",
+ openAiBaseUrl: "",
+ openAiModelId: "my-gpt4o-deployment",
+ openAiUseAzure: true,
+}
+
+export const OpenAICompatibleAzureFixture = () => (
+ translations[key] ?? key,
+ i18n: null as unknown as typeof import("../../../../i18n/setup").default,
+ }}>
+ translations[key] ?? key,
+ i18n: null as unknown as typeof import("../../../../i18n/setup").default,
+ }}>
+
+
+
+ {}}
+ organizationAllowList={{ allowAll: true, providers: {} }}
+ simplifySettings
+ />
+
+
+
+
+
+)
diff --git a/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.tsx b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.tsx
new file mode 100644
index 0000000000..0a522ca0b3
--- /dev/null
+++ b/webview-ui/src/components/settings/providers/__tests__/OpenAICompatible.visual.tsx
@@ -0,0 +1,17 @@
+import React from "react"
+
+import { expect, test } from "../../../../../playwright/coverage-fixture"
+import { OpenAICompatibleAzureFixture } from "./OpenAICompatible.visual.fixture"
+
+test("renders Azure OpenAI endpoint and deployment guidance in the VS Code dark theme", async ({ mount, page }) => {
+ // The full provider bundle leaves a bare Zod reference after CT tree-shaking.
+ await page.evaluate(() => Object.assign(globalThis, { z: undefined }))
+ const component = await mount()
+
+ await component.evaluate(async () => {
+ await document.fonts.ready
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()))
+ })
+
+ await expect(component).toHaveScreenshot("openai-compatible-azure-guidance-dark.png")
+})
diff --git a/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-compatible-azure-guidance-dark.png b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-compatible-azure-guidance-dark.png
new file mode 100644
index 0000000000..5838212a1d
Binary files /dev/null and b/webview-ui/src/components/settings/providers/__tests__/__screenshots__/openai-compatible-azure-guidance-dark.png differ
diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json
index 2aacc322f0..14a7476a75 100644
--- a/webview-ui/src/i18n/locales/en/settings.json
+++ b/webview-ui/src/i18n/locales/en/settings.json
@@ -575,6 +575,9 @@
"openAiApiKey": "OpenAI API Key",
"apiKey": "API Key",
"openAiBaseUrl": "Base URL",
+ "azureOpenAiBaseUrlPlaceholder": "https://.openai.azure.com/openai",
+ "azureOpenAiDeploymentName": "Azure deployment name",
+ "azureOpenAiDeploymentNameDescription": "Enter the deployment name from Azure AI Studio, not the underlying model name.",
"getOpenAiApiKey": "Get OpenAI API Key",
"mistralApiKey": "Mistral API Key",
"getMistralApiKey": "Get Mistral / Codestral API Key",