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
29 changes: 28 additions & 1 deletion src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 () {
Expand Down Expand Up @@ -74,6 +75,7 @@ vitest.mock("openai", () => {
},
}
}),
AzureOpenAI: mockAzureConstructor,
}
})

Expand Down Expand Up @@ -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", () => {
Expand Down
3 changes: 2 additions & 1 deletion src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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">
<label className="block font-medium mb-1">{t("settings:providers.openAiBaseUrl")}</label>
</VSCodeTextField>
Expand All @@ -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 && (
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.azureOpenAiDeploymentNameDescription")}
</div>
)}
<R1FormatSetting
onChange={handleInputChange("openAiR1FormatEnabled", noTransform)}
openAiR1FormatEnabled={apiConfiguration?.openAiR1FormatEnabled ?? false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,13 @@ vi.mock("@src/components/ui", () => ({
}))

// Mock other components
const { mockModelPicker } = vi.hoisted(() => ({ mockModelPicker: vi.fn() }))

vi.mock("../../ModelPicker", () => ({
ModelPicker: () => <div data-testid="model-picker">Model Picker</div>,
ModelPicker: (props: any) => {
mockModelPicker(props)
return <div data-testid="model-picker">Model Picker</div>
},
}))

vi.mock("../../R1FormatSetting", () => ({
Expand Down Expand Up @@ -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(
<OpenAICompatible
apiConfiguration={apiConfiguration as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)

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(
<OpenAICompatible
apiConfiguration={{ openAiBaseUrl: "https://models.example.com/v1" } as ProviderSettings}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)

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<ProviderSettings> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"settings:providers.openAiBaseUrl": "Base URL",
"settings:providers.azureOpenAiBaseUrlPlaceholder": "https://<resource>.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 = () => (
<PlaywrightTranslationContext.Provider
value={{
t: (key) => translations[key] ?? key,
i18n: null as unknown as typeof import("../../../../i18n/setup").default,
}}>
<AppTranslationContext.Provider
value={{
t: (key) => translations[key] ?? key,
i18n: null as unknown as typeof import("../../../../i18n/setup").default,
}}>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<div className="h-[295px] w-[480px] overflow-hidden bg-vscode-editor-background p-4 text-vscode-foreground">
<OpenAICompatible
apiConfiguration={apiConfiguration}
setApiConfigurationField={() => {}}
organizationAllowList={{ allowAll: true, providers: {} }}
simplifySettings
/>
</div>
</TooltipProvider>
</QueryClientProvider>
</AppTranslationContext.Provider>
</PlaywrightTranslationContext.Provider>
)
Original file line number Diff line number Diff line change
@@ -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(<OpenAICompatibleAzureFixture />)

await component.evaluate(async () => {
await document.fonts.ready
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
})

await expect(component).toHaveScreenshot("openai-compatible-azure-guidance-dark.png")
})
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,9 @@
"openAiApiKey": "OpenAI API Key",
"apiKey": "API Key",
"openAiBaseUrl": "Base URL",
"azureOpenAiBaseUrlPlaceholder": "https://<resource>.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",
Expand Down
Loading