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
>