Parameters
diff --git a/frontend/src/components/Config/CreateTargetDialog.styles.ts b/frontend/src/components/Config/CreateTargetDialog.styles.ts
index 67fdb2aa2f..f63ca604d7 100644
--- a/frontend/src/components/Config/CreateTargetDialog.styles.ts
+++ b/frontend/src/components/Config/CreateTargetDialog.styles.ts
@@ -14,4 +14,19 @@ export const useCreateTargetDialogStyles = makeStyles({
overflowWrap: 'anywhere',
wordBreak: 'break-word',
},
+ /** Container for the list of selected inner targets in the RoundRobin form. */
+ selectedTargetsList: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ },
+ /** A single row in the selected targets list: target name + weight + remove button. */
+ selectedTargetRow: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalS,
+ padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`,
+ backgroundColor: tokens.colorNeutralBackground2,
+ borderRadius: tokens.borderRadiusSmall,
+ },
})
diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx
index 69b7b65ab2..c825c32ac5 100644
--- a/frontend/src/components/Config/CreateTargetDialog.test.tsx
+++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx
@@ -2,6 +2,7 @@ import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import CreateTargetDialog from "./CreateTargetDialog";
+import { parseWeight, MAX_WEIGHT } from "./weightValidation";
import { targetsApi } from "@/services/api";
jest.mock("@/services/api", () => ({
@@ -28,6 +29,72 @@ async function selectTargetType(
await user.selectOptions(select, value);
}
+describe("parseWeight", () => {
+ it("rejects empty input", () => {
+ expect(parseWeight("")).toEqual({ ok: false, error: "Weight is required" });
+ });
+
+ it("rejects decimals like '2.5' (parseInt would silently truncate to 2)", () => {
+ expect(parseWeight("2.5")).toEqual({
+ ok: false,
+ error: "Weight must be a whole number",
+ });
+ });
+
+ it("rejects scientific notation like '1e10' (parseInt would silently return 1)", () => {
+ expect(parseWeight("1e10")).toEqual({
+ ok: false,
+ error: "Weight must be a whole number",
+ });
+ });
+
+ it("rejects negatives", () => {
+ expect(parseWeight("-3")).toEqual({
+ ok: false,
+ error: "Weight must be a whole number",
+ });
+ });
+
+ it("rejects whitespace and trailing characters", () => {
+ expect(parseWeight(" 5")).toEqual({
+ ok: false,
+ error: "Weight must be a whole number",
+ });
+ expect(parseWeight("5x")).toEqual({
+ ok: false,
+ error: "Weight must be a whole number",
+ });
+ });
+
+ it("rejects 0 with a 'must be at least 1' error (no silent revert)", () => {
+ expect(parseWeight("0")).toEqual({
+ ok: false,
+ error: "Weight must be at least 1",
+ });
+ });
+
+ it(`rejects values above MAX_WEIGHT (${MAX_WEIGHT})`, () => {
+ expect(parseWeight(String(MAX_WEIGHT + 1))).toEqual({
+ ok: false,
+ error: `Weight must be at most ${MAX_WEIGHT}`,
+ });
+ expect(parseWeight("99999999999")).toEqual({
+ ok: false,
+ error: `Weight must be at most ${MAX_WEIGHT}`,
+ });
+ });
+
+ it("accepts boundary values 1 and MAX_WEIGHT", () => {
+ expect(parseWeight("1")).toEqual({ ok: true, value: 1 });
+ expect(parseWeight(String(MAX_WEIGHT))).toEqual({ ok: true, value: MAX_WEIGHT });
+ });
+
+ it("accepts typical integer weights", () => {
+ expect(parseWeight("7")).toEqual({ ok: true, value: 7 });
+ expect(parseWeight("42")).toEqual({ ok: true, value: 42 });
+ });
+});
+
describe("CreateTargetDialog", () => {
const defaultProps = {
open: true,
@@ -361,7 +428,7 @@ describe("CreateTargetDialog", () => {
});
});
- it("should show generic error for non-Error exceptions", async () => {
+ it("should surface string throws verbatim via toApiError", async () => {
const user = userEvent.setup();
mockedTargetsApi.createTarget.mockRejectedValue("string error");
@@ -381,7 +448,7 @@ describe("CreateTargetDialog", () => {
await user.click(screen.getByText("Create Target"));
await waitFor(() => {
- expect(screen.getByText("Failed to create target")).toBeInTheDocument();
+ expect(screen.getByText("string error")).toBeInTheDocument();
});
});
@@ -797,4 +864,414 @@ describe("CreateTargetDialog", () => {
expect(mockedTargetsApi.createTarget).not.toHaveBeenCalled();
});
+
+ it("should show target picker when RoundRobinTarget is selected", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "RoundRobinTarget");
+
+ // Endpoint field should NOT be visible for RoundRobin
+ expect(
+ screen.queryByPlaceholderText("https://your-resource.openai.azure.com/")
+ ).not.toBeInTheDocument();
+
+ // Add Target dropdown should be visible
+ expect(screen.getByText("Add Target")).toBeInTheDocument();
+ });
+
+ it("should disable Create button when fewer than 2 inner targets are selected for RoundRobin", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "RoundRobinTarget");
+
+ const createButton = screen.getByText("Create Target").closest("button");
+ expect(createButton).toBeDisabled();
+ });
+
+ it("filters duplicate-by-identifier-hash targets out of the picker once one is selected", async () => {
+ const user = userEvent.setup();
+
+ // openai_a and openai_a_alias share an identifier_hash — they resolve to the
+ // same backend config, so once one is picked the other should disappear from
+ // the dropdown. openai_b has a different hash and stays.
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "RoundRobinTarget");
+
+ // Before selecting anything: all three are eligible.
+ const select = screen.getByText("Select a target to add...").closest("select")!;
+ expect(select.querySelector('option[value="openai_a"]')).not.toBeNull();
+ expect(select.querySelector('option[value="openai_a_alias"]')).not.toBeNull();
+ expect(select.querySelector('option[value="openai_b"]')).not.toBeNull();
+
+ // Pick openai_a.
+ await user.selectOptions(select, "openai_a");
+
+ // openai_a_alias (same hash) should now be filtered out, openai_b stays.
+ expect(select.querySelector('option[value="openai_a_alias"]')).toBeNull();
+ expect(select.querySelector('option[value="openai_b"]')).not.toBeNull();
+ });
+
+ it("applies underlying_model_name → model_name fallback when filtering compatible targets", async () => {
+ const user = userEvent.setup();
+
+ // foundry_a has no underlying_model_name but model_name='DeepSeek-R1' — the
+ // backend treats its effective underlying model as 'DeepSeek-R1' via the
+ // TARGET_EVAL_PARAM_FALLBACKS fallback. foundry_b also lacks underlying_model_name
+ // but has model_name='Gemini', which is a different effective underlying model.
+ // foundry_c is a true match: same effective underlying model as foundry_a.
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "RoundRobinTarget");
+ const select = screen.getByText("Select a target to add...").closest("select")!;
+ await user.selectOptions(select, "foundry_a");
+
+ // foundry_b's effective underlying model ('Gemini') differs from foundry_a's
+ // ('DeepSeek-R1') once the model_name fallback is applied, so it must be
+ // filtered out — the backend would reject the pair with HTTP 400.
+ expect(select.querySelector('option[value="foundry_b"]')).toBeNull();
+ // foundry_c shares the effective underlying model and stays eligible.
+ expect(select.querySelector('option[value="foundry_c"]')).not.toBeNull();
+ });
+
+ it("surfaces the backend error detail when target creation fails", async () => {
+ const user = userEvent.setup();
+
+ // Simulate an axios error with an RFC 7807 detail body — this is what the
+ // backend returns when, for example, RoundRobinTarget rejects an incompatible
+ // pair the frontend filter missed.
+ const axiosError = Object.assign(new Error("Request failed with status code 400"), {
+ isAxiosError: true,
+ response: {
+ status: 400,
+ data: {
+ detail:
+ "Behavioral parameter 'underlying_model_name' differs across targets: target 0 has 'DeepSeek-R1', target 1 has 'gemini-2.0-flash'.",
+ },
+ },
+ });
+ mockedTargetsApi.createTarget.mockRejectedValueOnce(axiosError);
+
+ render(
+
+
+
+ );
+
+ await selectTargetType(user, "RoundRobinTarget");
+ const select = screen.getByText("Select a target to add...").closest("select")!;
+ await user.selectOptions(select, "a");
+ await user.selectOptions(select, "b");
+
+ await user.click(screen.getByText("Create Target"));
+
+ await waitFor(() => {
+ // The backend's detail (the actual validation message) should be shown to
+ // the user, not the generic "Request failed with status code 400".
+ expect(screen.getByText(/Behavioral parameter/)).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/Request failed with status code 400/)).not.toBeInTheDocument();
+ });
+
+ // ===========================================================================
+ // RoundRobinTarget weight input validation
+ // ===========================================================================
+
+ /**
+ * Helper: render the dialog with two compatible inner targets already
+ * pickable, then select both — leaves the form in the state where every
+ * remaining concern is the weight inputs.
+ */
+ async function renderWithTwoRoundRobinTargetsSelected(): Promise<{
+ user: ReturnType
;
+ weightInputs: HTMLInputElement[];
+ }> {
+ const user = userEvent.setup();
+ render(
+
+
+
+ );
+ await selectTargetType(user, "RoundRobinTarget");
+ const select = screen.getByText("Select a target to add...").closest("select")!;
+ await user.selectOptions(select, "a");
+ await user.selectOptions(select, "b");
+ // Wait for both weight inputs to actually render — under load, React
+ // updates can lag behind the userEvent returns and queries return 0 or 1
+ // result, causing downstream assertions to operate on stale state.
+ await waitFor(
+ () => {
+ expect(screen.getAllByLabelText(/Weight for /)).toHaveLength(2);
+ },
+ { timeout: 10000 },
+ );
+ const weightInputs = screen.getAllByLabelText(/Weight for /) as HTMLInputElement[];
+ return { user, weightInputs };
+ }
+
+ // Note: exhaustive validation of decimal, scientific notation, negative,
+ // zero, and out-of-range cases is covered by the parseWeight unit tests
+ // above. These integration tests only verify the UI wiring: that invalid
+ // input surfaces an alert + disables Create, and that valid input round
+ // trips through createTarget as parsed ints.
+
+ it("shows an alert and disables Create when a weight is invalid", async () => {
+ const { user, weightInputs } = await renderWithTwoRoundRobinTargetsSelected();
+
+ // Use fireEvent.change to bypass HTML5 step="1" constraint that
+ // userEvent.type would respect. We specifically want to verify our JS
+ // validation catches values that bypass browser-level checks.
+ fireEvent.change(weightInputs[0], { target: { value: "2.5" } });
+
+ // Re-query state under waitFor — under heavy load React commits can lag
+ // behind fireEvent's return, and stale references won't reflect updates.
+ await waitFor(
+ () => {
+ const inputs = screen.getAllByLabelText(/Weight for /) as HTMLInputElement[];
+ expect(inputs[0].value).toBe("2.5");
+ expect(inputs[0].getAttribute("aria-invalid")).toBe("true");
+ },
+ { timeout: 10000 },
+ );
+
+ expect(screen.getByText("Weight must be a whole number")).toBeInTheDocument();
+ expect(screen.getByText("Create Target").closest("button")).toBeDisabled();
+
+ // Pressing Enter inside the weight input must not bypass the disabled
+ // button and submit the form.
+ await user.click(screen.getByText("Create Target"));
+ expect(mockedTargetsApi.createTarget).not.toHaveBeenCalled();
+ }, 30000);
+
+ it("submits parsed integer weights when all inputs are valid", async () => {
+ mockedTargetsApi.createTarget.mockResolvedValueOnce({
+ target_registry_name: "rr",
+ } as unknown as Awaited>);
+
+ const { user, weightInputs } = await renderWithTwoRoundRobinTargetsSelected();
+ fireEvent.change(weightInputs[0], { target: { value: "7" } });
+ fireEvent.change(weightInputs[1], { target: { value: "42" } });
+
+ // Wait until both inputs reflect the new values and Create is enabled
+ // (which only happens once both weights have flushed to state and parsed
+ // successfully).
+ await waitFor(
+ () => {
+ const inputs = screen.getAllByLabelText(/Weight for /) as HTMLInputElement[];
+ expect(inputs[0].value).toBe("7");
+ expect(inputs[1].value).toBe("42");
+ expect(screen.getByText("Create Target").closest("button")).not.toBeDisabled();
+ },
+ { timeout: 10000 },
+ );
+
+ await user.click(screen.getByText("Create Target"));
+
+ await waitFor(
+ () => {
+ expect(mockedTargetsApi.createTarget).toHaveBeenCalledTimes(1);
+ },
+ { timeout: 10000 },
+ );
+ const call = mockedTargetsApi.createTarget.mock.calls[0][0];
+ expect(call.type).toBe("RoundRobinTarget");
+ expect(call.params?.weights).toEqual([7, 42]);
+ }, 30000);
+
+ it("removes a selected inner target when its delete button is clicked", async () => {
+ const { user } = await renderWithTwoRoundRobinTargetsSelected();
+
+ // Both targets show up as selected rows.
+ expect(screen.getAllByLabelText(/Weight for /)).toHaveLength(2);
+
+ await user.click(screen.getByLabelText("Remove a"));
+
+ await waitFor(
+ () => {
+ expect(screen.getAllByLabelText(/Weight for /)).toHaveLength(1);
+ },
+ { timeout: 10000 },
+ );
+ // With only one inner target left, Create is disabled (needs >= 2).
+ expect(screen.getByText("Create Target").closest("button")).toBeDisabled();
+ }, 30000);
+
+ it("submit-time guards reject invalid weights even if the disabled button is bypassed", async () => {
+ // Re-validating at submit time defends against pressing Enter inside the
+ // weight input, which submits the form regardless of the button's disabled
+ // state. We exercise that branch directly by submitting the form element.
+ const { weightInputs } = await renderWithTwoRoundRobinTargetsSelected();
+
+ fireEvent.change(weightInputs[0], { target: { value: "2.5" } });
+ await waitFor(
+ () => {
+ expect(screen.getByText("Create Target").closest("button")).toBeDisabled();
+ },
+ { timeout: 10000 },
+ );
+
+ // Find the form (the dialog wraps the fields in a
@@ -362,7 +635,14 @@ export default function CreateTargetDialog({ open, onClose, onCreated }: CreateT
diff --git a/frontend/src/components/Config/TargetConfig.tsx b/frontend/src/components/Config/TargetConfig.tsx
index 80e3419c84..d9b858d3b4 100644
--- a/frontend/src/components/Config/TargetConfig.tsx
+++ b/frontend/src/components/Config/TargetConfig.tsx
@@ -135,6 +135,7 @@ export default function TargetConfig({ activeTarget, onSetActiveTarget }: Target
open={dialogOpen}
onClose={() => setDialogOpen(false)}
onCreated={handleTargetCreated}
+ existingTargets={targets}
/>
)
diff --git a/frontend/src/components/Config/TargetTable.styles.ts b/frontend/src/components/Config/TargetTable.styles.ts
index 906db7578c..23ce7d25c6 100644
--- a/frontend/src/components/Config/TargetTable.styles.ts
+++ b/frontend/src/components/Config/TargetTable.styles.ts
@@ -72,4 +72,10 @@ export const useTargetTableStyles = makeStyles({
helpHeader: {
cursor: 'help',
},
+ /** Sub-row for inner targets of a RoundRobinTarget — visually indented with a
+ * lighter background so it's clear these are children, not standalone targets. */
+ innerTargetRow: {
+ backgroundColor: tokens.colorNeutralBackground2,
+ opacity: 0.85,
+ },
})
diff --git a/frontend/src/components/Config/TargetTable.test.tsx b/frontend/src/components/Config/TargetTable.test.tsx
index 51289614ec..17408d6d3c 100644
--- a/frontend/src/components/Config/TargetTable.test.tsx
+++ b/frontend/src/components/Config/TargetTable.test.tsx
@@ -344,4 +344,57 @@ describe('TargetTable', () => {
expect(screen.queryByText('Filter by type:')).not.toBeInTheDocument()
})
+
+ it('should show expand button for RoundRobinTarget with inner targets', () => {
+ const rrTarget: TargetInstance = {
+ target_registry_name: 'rr_gpt4o',
+ target_type: 'RoundRobinTarget',
+ model_name: 'gpt-4o',
+ target_specific_params: { weights: [1, 1] },
+ inner_targets: [
+ {
+ target_registry_name: 'inner_a',
+ target_type: 'OpenAIChatTarget',
+ endpoint: 'https://a.openai.azure.com',
+ model_name: 'gpt-4o',
+ },
+ {
+ target_registry_name: 'inner_b',
+ target_type: 'OpenAIChatTarget',
+ endpoint: 'https://b.openai.azure.com',
+ model_name: 'gpt-4o',
+ },
+ ],
+ }
+
+ render(
+