diff --git a/frontend/src/components/Config/CreateTargetDialog.styles.ts b/frontend/src/components/Config/CreateTargetDialog.styles.ts index 34f023786..67fdb2aa2 100644 --- a/frontend/src/components/Config/CreateTargetDialog.styles.ts +++ b/frontend/src/components/Config/CreateTargetDialog.styles.ts @@ -6,4 +6,12 @@ export const useCreateTargetDialogStyles = makeStyles({ flexDirection: 'column', gap: tokens.spacingVerticalL, }, + warningMessage: { + width: '100%', + }, + warningMessageBody: { + whiteSpace: 'normal', + overflowWrap: 'anywhere', + wordBreak: 'break-word', + }, }) diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx index b8a9dcfc0..21741f698 100644 --- a/frontend/src/components/Config/CreateTargetDialog.test.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx @@ -72,6 +72,30 @@ describe("CreateTargetDialog", () => { expect(createButton.closest("button")).toBeDisabled(); }); + it("should hide the Authentication field until a target type is selected", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + // No type is chosen yet, so authentication option is not visible yet, but plain API Key input is. + expect( + screen.queryByRole("radio", { name: /Microsoft Entra Authentication/ }) + ).not.toBeInTheDocument(); + expect( + screen.getByPlaceholderText("API key (stored in memory only)") + ).toBeInTheDocument(); + + // Selecting an Entra-capable type should reveal the Authentication field. + await selectTargetType(user, "OpenAIChatTarget"); + expect( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ).toBeInTheDocument(); + }); + it("should call onClose when Cancel is clicked", async () => { const onClose = jest.fn(); const user = userEvent.setup(); @@ -104,14 +128,12 @@ describe("CreateTargetDialog", () => { // Select target type await selectTargetType(user, "OpenAIChatTarget"); - // Fill endpoint — use fireEvent.change because userEvent.type truncates - // URLs containing periods in FluentUI Input under jsdom. + // Fill the endpoint & model names const endpointInput = screen.getByPlaceholderText( "https://your-resource.openai.azure.com/" ); fireEvent.change(endpointInput, { target: { value: "https://api.openai.com" } }); - // Fill model name — use fireEvent.change for consistency (same reason as endpoint) const modelInput = screen.getByPlaceholderText("e.g. gpt-4o, my-deployment"); fireEvent.change(modelInput, { target: { value: "gpt-4" } }); @@ -147,13 +169,12 @@ describe("CreateTargetDialog", () => { // Select target type await selectTargetType(user, "OpenAIChatTarget"); - // Fill endpoint + // Fill endpoint & model names const endpointInput = screen.getByPlaceholderText( "https://your-resource.openai.azure.com/" ); fireEvent.change(endpointInput, { target: { value: "https://api.azure.com" } }); - // Fill model name const modelInput = screen.getByPlaceholderText("e.g. gpt-4o, my-deployment"); fireEvent.change(modelInput, { target: { value: "my-gpt4o-deployment" } }); @@ -497,4 +518,268 @@ describe("CreateTargetDialog", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + + it("should hide the API Key field and omit api_key/include auth_mode when Entra is selected", async () => { + const onCreated = jest.fn(); + const user = userEvent.setup(); + mockedTargetsApi.createTarget.mockResolvedValue({ + target_registry_name: "openai_chat_entra", + target_type: "OpenAIChatTarget", + }); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { + target: { value: "https://my-resource.openai.azure.com/" }, + }); + + // check that API Key field is visible by default. + expect( + screen.getByPlaceholderText("API key (stored in memory only)") + ).toBeInTheDocument(); + + // Select Entra option. + await user.click( + screen.getByRole("radio", { + name: /Microsoft Entra Authentication/, + }) + ); + + // check that API Key field is hidden when Entra mode is selected. + expect( + screen.queryByPlaceholderText("API key (stored in memory only)") + ).not.toBeInTheDocument(); + + await user.click(screen.getByText("Create Target")); + + await waitFor(() => { + expect(mockedTargetsApi.createTarget).toHaveBeenCalledWith({ + type: "OpenAIChatTarget", + params: { + endpoint: "https://my-resource.openai.azure.com/", + }, + auth_mode: "entra", + }); + expect(onCreated).toHaveBeenCalled(); + }); + }); + + it("should clear a previously-typed API key when switching to Entra", async () => { + const onCreated = jest.fn(); + const user = userEvent.setup(); + mockedTargetsApi.createTarget.mockResolvedValue({ + target_registry_name: "openai_chat_entra", + target_type: "OpenAIChatTarget", + }); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { + target: { value: "https://my-resource.openai.azure.com/" }, + }); + + // Type a key, then switch to Entra option. + fireEvent.change( + screen.getByPlaceholderText("API key (stored in memory only)"), + { target: { value: "sk-typed-before-switch" } } + ); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + await user.click(screen.getByText("Create Target")); + + await waitFor(() => { + const call = mockedTargetsApi.createTarget.mock.calls[0][0]; + expect(call.auth_mode).toBe("entra"); + expect(call.params).not.toHaveProperty("api_key"); + }); + }); + + it("should warn the user when Entra is selected for a non-Azure OpenAI endpoint", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { target: { value: "https://api.openai.com/" } }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + expect( + screen.getByText(/Entra auth only works with Azure OpenAI/) + ).toBeInTheDocument(); + }); + + it("should NOT warn when Entra is selected for a recognized Azure endpoint", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { + target: { value: "https://my-resource.openai.azure.com/" }, + }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + expect( + screen.queryByText(/Entra auth only works with Azure OpenAI/) + ).not.toBeInTheDocument(); + }); + + it("should disable Create Target and skip API call for Entra + non-Azure OpenAI endpoint", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "OpenAIChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-resource.openai.azure.com/" + ); + fireEvent.change(endpointInput, { target: { value: "https://api.test.com/" } }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + const createButton = screen.getByText("Create Target").closest("button"); + expect(createButton).toBeDisabled(); + + await user.click(screen.getByText("Create Target")); + + expect(mockedTargetsApi.createTarget).not.toHaveBeenCalled(); + }); + + it("should warn the user when Entra is selected for a non-AML endpoint on AzureMLChatTarget", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "AzureMLChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-model.region.inference.ml.azure.com/score" + ); + fireEvent.change(endpointInput, { + target: { value: "https://example.com/score" }, + }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + expect( + screen.getByText( + /Entra auth for AzureMLChatTarget only works with Azure ML managed online endpoints/ + ) + ).toBeInTheDocument(); + }); + + it("should NOT warn when Entra is selected for a recognized AML endpoint", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "AzureMLChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-model.region.inference.ml.azure.com/score" + ); + fireEvent.change(endpointInput, { + target: { value: "https://my-llama.eastus.inference.ml.azure.com/score" }, + }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + expect( + screen.queryByText( + /Entra auth for AzureMLChatTarget only works with Azure ML managed online endpoints/ + ) + ).not.toBeInTheDocument(); + }); + + it("should disable Create Target and skip API call for Entra + non-AML endpoint on AzureMLChatTarget", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + await selectTargetType(user, "AzureMLChatTarget"); + + const endpointInput = screen.getByPlaceholderText( + "https://your-model.region.inference.ml.azure.com/score" + ); + fireEvent.change(endpointInput, { + target: { value: "https://api.test.com/score" }, + }); + + await user.click( + screen.getByRole("radio", { name: /Microsoft Entra Authentication/ }) + ); + + const createButton = screen.getByText("Create Target").closest("button"); + expect(createButton).toBeDisabled(); + + await user.click(screen.getByText("Create Target")); + + expect(mockedTargetsApi.createTarget).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx index 4bce08f56..02f94bb9c 100644 --- a/frontend/src/components/Config/CreateTargetDialog.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.tsx @@ -9,6 +9,8 @@ import { Button, Input, Label, + Radio, + RadioGroup, Select, Switch, Text, @@ -20,18 +22,57 @@ import { import { targetsApi } from '@/services/api' import { useCreateTargetDialogStyles } from './CreateTargetDialog.styles' -const TARGET_TYPE_CONFIG: Record = { - OpenAIChatTarget: 'openai', - OpenAICompletionTarget: 'openai', - OpenAIImageTarget: 'openai', - OpenAIVideoTarget: 'openai', - OpenAITTSTarget: 'openai', - OpenAIResponseTarget: 'openai', - AzureMLChatTarget: 'azureml', +interface TargetTypeConfig { + readonly kind: 'openai' | 'azureml' + readonly supportsEntra: boolean +} + +const TARGET_TYPE_CONFIG: Record = { + OpenAIChatTarget: { kind: 'openai', supportsEntra: true }, + OpenAICompletionTarget: { kind: 'openai', supportsEntra: true }, + OpenAIImageTarget: { kind: 'openai', supportsEntra: true }, + OpenAIVideoTarget: { kind: 'openai', supportsEntra: true }, + OpenAITTSTarget: { kind: 'openai', supportsEntra: true }, + OpenAIResponseTarget: { kind: 'openai', supportsEntra: true }, + AzureMLChatTarget: { kind: 'azureml', supportsEntra: true }, } const SUPPORTED_TARGET_TYPES = Object.keys(TARGET_TYPE_CONFIG) +type AuthMode = 'api_key' | 'entra' + +// Mirrors backend's hostname-suffix check (list in target_service.py). +// The backend still does the check and will reject unsupported endpoints, but this allows us to show a warning in the UI if the user selects Microsoft Entra authentication with a non-Azure OpenAI endpoint. +const AZURE_OPENAI_HOSTNAME_SUFFIXES = [ + '.openai.azure.com', + '.ai.azure.com', + '.services.ai.azure.com', + '.cognitiveservices.azure.com', +] + +// Mirrors backend's hostname-suffix check for Azure ML managed online endpoints +// (list in target_service.py). Used to warn the user when Microsoft Entra +// authentication is selected with a non-AML endpoint for AzureMLChatTarget. +const AZURE_ML_HOSTNAME_SUFFIXES = ['.inference.ml.azure.com'] + +function isAzureOpenAiEndpoint(endpoint: string): boolean { + try { + const host = new URL(endpoint).hostname.toLowerCase() + return AZURE_OPENAI_HOSTNAME_SUFFIXES.some((s) => host.endsWith(s)) + } catch { + return false + } +} + +function isAzureMlEndpoint(endpoint: string): boolean { + try { + const host = new URL(endpoint).hostname.toLowerCase() + return AZURE_ML_HOSTNAME_SUFFIXES.some((s) => host.endsWith(s)) + } catch { + return false + } +} + interface CreateTargetDialogProps { open: boolean onClose: () => void @@ -45,6 +86,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT const [modelName, setModelName] = useState('') const [hasDifferentUnderlying, setHasDifferentUnderlying] = useState(false) const [underlyingModel, setUnderlyingModel] = useState('') + const [authMode, setAuthMode] = useState('api_key') const [apiKey, setApiKey] = useState('') const [maxNewTokens, setMaxNewTokens] = useState('400') const [temperature, setTemperature] = useState('1.0') @@ -54,7 +96,23 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT const [error, setError] = useState(null) const [fieldErrors, setFieldErrors] = useState<{ targetType?: string; endpoint?: string }>({}) - const isAzureML = TARGET_TYPE_CONFIG[targetType] === 'azureml' + const targetConfig = TARGET_TYPE_CONFIG[targetType] + const isAzureML = targetConfig?.kind === 'azureml' + const isOpenAi = targetConfig?.kind === 'openai' + const supportsEntra = targetConfig?.supportsEntra ?? false + const showAuthField = targetType !== '' && supportsEntra + const isEntra = showAuthField && authMode === 'entra' + const entraEndpointError: string | null = (() => { + if (!isEntra || endpoint === '') return null + if (isOpenAi && !isAzureOpenAiEndpoint(endpoint)) { + return 'Entra auth only works with Azure OpenAI / AI Foundry endpoints (for example, *.openai.azure.com or *.ai.azure.com).' + } + if (isAzureML && !isAzureMlEndpoint(endpoint)) { + return 'Entra auth for AzureMLChatTarget only works with Azure ML managed online endpoints (for example, *.inference.ml.azure.com).' + } + return null + })() + const showEntraEndpointError = entraEndpointError !== null const resetForm = () => { setTargetType('') @@ -62,6 +120,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT setModelName('') setHasDifferentUnderlying(false) setUnderlyingModel('') + setAuthMode('api_key') setApiKey('') setMaxNewTokens('400') setTemperature('1.0') @@ -94,7 +153,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT endpoint, } if (modelName) params.model_name = modelName - if (apiKey) params.api_key = apiKey + if (!isEntra && apiKey) params.api_key = apiKey if (hasDifferentUnderlying && underlyingModel) params.underlying_model = underlyingModel @@ -112,6 +171,7 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT await targetsApi.createTarget({ type: targetType, params, + ...(isEntra ? { auth_mode: 'entra' as const } : {}), }) resetForm() onCreated() @@ -147,7 +207,13 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT > setApiKey(data.value)} - /> - + {showAuthField && ( + + { + const next = data.value as AuthMode + setAuthMode(next) + if (next === 'entra') setApiKey('') + }} + > + + + + + )} + + {showEntraEndpointError && ( + + + {entraEndpointError} + + + )} + + {!isEntra && ( + + setApiKey(data.value)} + /> + + )}