Skip to content
Merged
31 changes: 31 additions & 0 deletions packages/types/src/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,3 +705,34 @@ export const azureOpenAiDefaultApiVersion = "2024-08-01-preview"
export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0

export const OPENAI_AZURE_AI_INFERENCE_PATH = "/models/chat/completions"

/**
* Returns true when the base URL belongs to Azure AI Inference.
* These endpoints use the regular OpenAI client and expect a model identifier,
* even when the Azure compatibility flag is enabled.
*/
export function isAzureAiInferenceBaseUrl(baseUrl?: string): boolean {
try {
const host = new URL(baseUrl ?? "").host
return host.endsWith(".services.ai.azure.com")
} catch {
return false
}
}

/**
* Returns true when the base URL and/or flag indicate an Azure OpenAI endpoint.
* Azure AI Inference endpoints (*.services.ai.azure.com) return false — the
* backend routes those through the plain OpenAI client, not AzureOpenAI.
*/
export function isAzureOpenAiBaseUrl(baseUrl?: string, useAzure?: boolean): boolean {
if (isAzureAiInferenceBaseUrl(baseUrl)) return false
if (useAzure) return true
Comment thread
edelauna marked this conversation as resolved.

try {
const host = new URL(baseUrl ?? "").host
return host === "azure.com" || host.endsWith(".azure.com")
} catch {
return false
}
}
57 changes: 55 additions & 2 deletions src/api/providers/__tests__/openai.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import { OpenAiHandler, getOpenAiModels } from "../openai"
import { ApiHandlerOptions } from "../../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { openAiModelInfoSaneDefaults, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types"
import OpenAI, { AzureOpenAI } from "openai"
import {
openAiModelInfoSaneDefaults,
DEEP_SEEK_DEFAULT_TEMPERATURE,
azureOpenAiDefaultApiVersion,
} from "@roo-code/types"
import { Package } from "../../../shared/package"
import { makeApiHandlerOptions } from "../../../test-utils/api"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"
Expand All @@ -20,6 +24,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 +79,7 @@ vitest.mock("openai", () => {
},
}
}),
AzureOpenAI: mockAzureConstructor,
}
})

Expand Down Expand Up @@ -126,6 +132,43 @@ 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,
apiKey: mockOptions.openAiApiKey,
apiVersion: azureOpenAiDefaultApiVersion,
defaultHeaders: expect.any(Object),
timeout: MOCK_TIMEOUT_MS,
}),
)
})

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",
apiKey: mockOptions.openAiApiKey,
apiVersion: azureOpenAiDefaultApiVersion,
defaultHeaders: expect.any(Object),
timeout: MOCK_TIMEOUT_MS,
}),
)
})
})

describe("createMessage", () => {
Expand Down Expand Up @@ -854,6 +897,16 @@ describe("OpenAiHandler", () => {
expect(azureHandler.getModel().id).toBe(azureOptions.openAiModelId)
})

it("should keep Azure AI Inference precedence when Azure mode is enabled", () => {
vi.mocked(OpenAI).mockClear()
vi.mocked(AzureOpenAI).mockClear()

new OpenAiHandler({ ...azureOptions, openAiUseAzure: true })

expect(vi.mocked(OpenAI)).toHaveBeenCalled()
expect(vi.mocked(AzureOpenAI)).not.toHaveBeenCalled()
})

it("should handle streaming responses with Azure AI Inference Service", async () => {
const azureHandler = new OpenAiHandler(azureOptions)
const systemPrompt = "You are a helpful assistant."
Expand Down
11 changes: 6 additions & 5 deletions src/api/providers/openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import axios from "axios"
import {
type ModelInfo,
azureOpenAiDefaultApiVersion,
isAzureAiInferenceBaseUrl,
isAzureOpenAiBaseUrl,
openAiModelInfoSaneDefaults,
DEEP_SEEK_DEFAULT_TEMPERATURE,
OPENAI_AZURE_AI_INFERENCE_PATH,
Expand Down Expand Up @@ -40,8 +42,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1"
const apiKey = this.options.openAiApiKey ?? "not-provided"
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
const isAzureOpenAi = isAzureOpenAiBaseUrl(this.options.openAiBaseUrl, options.openAiUseAzure)

const headers = {
...DEFAULT_HEADERS,
Expand All @@ -60,8 +61,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\/?$/i, "").replace(/\/$/, "")}/openai`
this.client = new AzureOpenAI({
baseURL,
baseURL: azureBaseURL,
apiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: headers,
Expand Down Expand Up @@ -520,8 +522,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}

protected _isAzureAiInference(baseUrl?: string): boolean {
const urlHost = this._getUrlHost(baseUrl)
return urlHost.endsWith(".services.ai.azure.com")
return isAzureAiInferenceBaseUrl(baseUrl)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type OrganizationAllowList,
type ExtensionMessage,
azureOpenAiDefaultApiVersion,
isAzureOpenAiBaseUrl,
openAiModelInfoSaneDefaults,
} from "@roo-code/types"

Expand Down Expand Up @@ -42,6 +43,7 @@ export const OpenAICompatible = ({
simplifySettings,
}: OpenAICompatibleProps) => {
const { t } = useAppTranslation()
const isAzureOpenAi = isAzureOpenAiBaseUrl(apiConfiguration?.openAiBaseUrl, apiConfiguration?.openAiUseAzure)

const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion)

Expand Down Expand Up @@ -129,7 +131,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 +153,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,78 @@ 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()
})

it("keeps generic OpenAI-compatible guidance for Azure AI Inference endpoints", () => {
render(
<OpenAICompatible
apiConfiguration={
{ openAiBaseUrl: "https://my-resource.services.ai.azure.com/models" } as ProviderSettings
}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)

expect(mockModelPicker).toHaveBeenLastCalledWith(expect.objectContaining({ label: undefined }))
expect(
screen.queryByText("settings:providers.azureOpenAiDeploymentNameDescription"),
).not.toBeInTheDocument()
})

it("keeps generic guidance when Azure AI Inference uses the Azure compatibility flag", () => {
render(
<OpenAICompatible
apiConfiguration={
{
openAiBaseUrl: "https://my-resource.services.ai.azure.com/models",
openAiUseAzure: true,
} as ProviderSettings
}
setApiConfigurationField={mockSetApiConfigurationField}
organizationAllowList={mockOrganizationAllowList}
/>,
)

expect(screen.getByPlaceholderText("settings:placeholders.baseUrl")).toBeInTheDocument()
expect(mockModelPicker).toHaveBeenLastCalledWith(expect.objectContaining({ label: undefined }))
})
})

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,66 @@
/* 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"
import enSettings from "@/i18n/locales/en/settings.json"

function flattenTranslations(obj: Record<string, unknown>, prefix = "settings:"): Record<string, string> {
const result: Record<string, string> = {}
for (const [key, value] of Object.entries(obj)) {
const fullKey = `${prefix}${key}`
if (typeof value === "string") {
result[fullKey] = value
} else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
Object.assign(result, flattenTranslations(value as Record<string, unknown>, `${fullKey}.`))
}
}
return result
}

const translations = flattenTranslations(enSettings as Record<string, unknown>)

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>
)
Loading
Loading