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",