Add embedding source validation rules

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Archie Agarwal
2026-09-13 22:51:35 +05:30
parent d4fd0150e9
commit af7aa16ee7
5 changed files with 228 additions and 28 deletions
@@ -150,30 +150,54 @@ describe("VectorEmbeddingPoliciesComponent - embedding source", () => {
});
await waitFor(() => {
expect(screen.getByText("At least one source path is required")).toBeInTheDocument();
expect(screen.getByText("Model name is required")).toBeInTheDocument();
expect(screen.getByText("Endpoint is required")).toBeInTheDocument();
expect(screen.getByText("Embedding model name is required")).toBeInTheDocument();
expect(screen.getByText("Microsoft Foundry Endpoint is required")).toBeInTheDocument();
});
const last = onChange.mock.calls[onChange.mock.calls.length - 1];
expect(last[2]).toBe(false);
});
test("invalid endpoint shows the https:// error", async () => {
test("source paths must start with slash and differ from vector path", async () => {
expandSection();
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
target: { value: "description" },
});
await waitFor(() => expect(screen.getByText("Source paths must start with /")).toBeInTheDocument());
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
target: { value: "/vector2" },
});
await waitFor(() => expect(screen.getByText("Source path must be different from vector path")).toBeInTheDocument());
});
test("invalid endpoint shows the Azure OpenAI or Foundry URL error", async () => {
expandSection();
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
target: { value: "not-a-url" },
});
await waitFor(() => expect(screen.getByText("Endpoint must be a valid https:// URL")).toBeInTheDocument());
await waitFor(() =>
expect(screen.getByText("Endpoint must be a valid Azure OpenAI or Foundry https:// URL")).toBeInTheDocument(),
);
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
target: { value: "http://insecure.example.com" },
});
await waitFor(() => expect(screen.getByText("Endpoint must be a valid https:// URL")).toBeInTheDocument());
await waitFor(() =>
expect(screen.getByText("Endpoint must be a valid Azure OpenAI or Foundry https:// URL")).toBeInTheDocument(),
);
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
target: { value: "https://example.com" },
});
await waitFor(() =>
expect(screen.getByText("Endpoint must be a valid Azure OpenAI or Foundry https:// URL")).toBeInTheDocument(),
);
});
test("valid input propagates an embeddingSource with parsed sourcePaths", async () => {
expandSection();
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
target: { value: "/description, title" },
target: { value: "/description, /title" },
});
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), {
target: { value: "my-deployment" },
@@ -213,7 +237,7 @@ describe("VectorEmbeddingPoliciesComponent - embedding source", () => {
fireEvent.change(sourcePaths, { target: { value: "/description" } });
fireEvent.change(deploymentName, { target: { value: "d" } });
fireEvent.change(modelName, { target: { value: "m" } });
fireEvent.change(endpoint, { target: { value: "https://x.example.com" } });
fireEvent.change(endpoint, { target: { value: "https://x.openai.azure.com" } });
await waitFor(() => {
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
@@ -264,6 +288,102 @@ describe("VectorEmbeddingPoliciesComponent - embedding source", () => {
await new Promise((resolve) => setTimeout(resolve, 100));
expect(onChange.mock.calls.length).toBe(stable);
});
test("model-specific dimension validation blocks out-of-range values", async () => {
expandSection();
fireEvent.change(view.container.querySelector("#vector-policy-dimension-1"), { target: { value: "3073" } });
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), {
target: { value: "/description" },
});
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), {
target: { value: "text-embedding-3-large" },
});
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-modelName-1"), {
target: { value: "text-embedding-3-large" },
});
fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), {
target: { value: "https://my-foundry.openai.azure.com" },
});
await waitFor(() => {
expect(screen.getByText("Dimension must be greater than 0 and less than or equal 3072")).toBeInTheDocument();
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
expect(lastCall[2]).toBe(false);
});
});
test("existing embedding source allows endpoint edit but keeps source fields read-only", async () => {
const existingEmbedding: VectorEmbedding[] = [
{
path: "/vector4",
dataType: "float32",
distanceFunction: "cosine",
dimensions: 1536,
embeddingSource: {
sourcePaths: ["/description"],
deploymentName: "text-embedding-3-small",
modelName: "text-embedding-3-small",
endpoint: "https://old.openai.azure.com",
authType: "Entra",
},
},
];
const existingOnChange = jest.fn();
const existingView = render(
<VectorEmbeddingPoliciesComponent
vectorEmbeddingsBaseline={existingEmbedding}
vectorEmbeddings={existingEmbedding}
vectorIndexes={[]}
onVectorEmbeddingChange={existingOnChange}
/>,
);
const sourcePaths = existingView.container.querySelector(
"#vector-policy-embeddingSource-sourcePaths-1",
) as HTMLInputElement;
const endpoint = existingView.container.querySelector(
"#vector-policy-embeddingSource-endpoint-1",
) as HTMLInputElement;
expect(sourcePaths).toBeDisabled();
expect(endpoint).not.toBeDisabled();
fireEvent.change(endpoint, { target: { value: "https://new.openai.azure.com" } });
await waitFor(() => {
const lastCall = existingOnChange.mock.calls[existingOnChange.mock.calls.length - 1];
expect(lastCall[2]).toBe(true);
expect(lastCall[0][0].embeddingSource.endpoint).toBe("https://new.openai.azure.com");
});
});
test("existing vector policy without an embedding source cannot add one", () => {
const existingEmbedding: VectorEmbedding[] = [
{
path: "/vector5",
dataType: "float32",
distanceFunction: "cosine",
dimensions: 1536,
},
];
const existingView = render(
<VectorEmbeddingPoliciesComponent
vectorEmbeddingsBaseline={existingEmbedding}
vectorEmbeddings={existingEmbedding}
vectorIndexes={[]}
onVectorEmbeddingChange={jest.fn()}
/>,
);
fireEvent.click(existingView.container.querySelector('[data-test="VectorEmbeddingSource/Section/1"]'));
expect(existingView.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).toBeDisabled();
expect(existingView.container.querySelector("#vector-policy-embeddingSource-deploymentName-1")).toBeDisabled();
expect(existingView.container.querySelector("#vector-policy-embeddingSource-modelName-1")).toBeDisabled();
expect(existingView.container.querySelector("#vector-policy-embeddingSource-authType-1")).toHaveAttribute(
"aria-disabled",
"true",
);
expect(existingView.container.querySelector("#vector-policy-embeddingSource-endpoint-1")).not.toBeDisabled();
});
});
describe("VectorEmbeddingPoliciesComponent - embedding source gating", () => {
@@ -41,6 +41,7 @@ export interface VectorEmbeddingPolicyData {
distanceFunction: VectorEmbedding["distanceFunction"];
dimensions: number;
indexType: VectorIndex["type"] | "none";
dataTypeError: string;
pathError: string;
dimensionsError: string;
vectorIndexShardKey?: string[];
@@ -54,6 +55,18 @@ export interface VectorEmbeddingPolicyData {
}
type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType";
const embeddingSourceSupportedDataTypes: VectorEmbedding["dataType"][] = ["float32", "float16"];
const getEmbeddingSourceDimensionLimit = (modelName: string | undefined): number | undefined => {
switch (modelName?.trim()) {
case "text-embedding-3-large":
return 3072;
case "text-embedding-3-small":
return 1536;
default:
return undefined;
}
};
export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddingPoliciesComponentProps> = ({
vectorEmbeddingsBaseline,
@@ -95,7 +108,21 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
return error;
};
const onVectorEmbeddingDimensionError = (dimension: number, indexType: VectorIndex["type"] | "none"): string => {
const onVectorEmbeddingDataTypeError = (
dataType: VectorEmbedding["dataType"],
embeddingSource?: VectorEmbeddingSource,
): string => {
if (embeddingSource && !embeddingSourceSupportedDataTypes.includes(dataType)) {
return t(Keys.controls.vectorEmbeddingPolicies.embeddingSourceDataTypeError);
}
return "";
};
const onVectorEmbeddingDimensionError = (
dimension: number,
indexType: VectorIndex["type"] | "none",
embeddingSource?: VectorEmbeddingSource,
): string => {
let error = "";
if (dimension <= 0 || dimension > 4096) {
error = t(Keys.controls.vectorEmbeddingPolicies.dimensionRangeError);
@@ -103,6 +130,15 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
if (indexType === "flat" && dimension > 505) {
error = t(Keys.controls.vectorEmbeddingPolicies.dimensionFlatIndexError);
}
if (embeddingSource?.modelName === "text-embedding-ada-002" && dimension !== 1536) {
error = t(Keys.controls.vectorEmbeddingPolicies.adaDimensionError);
}
const modelDimensionLimit = getEmbeddingSourceDimensionLimit(embeddingSource?.modelName);
if (modelDimensionLimit && (dimension <= 0 || dimension > modelDimensionLimit)) {
error = t(Keys.controls.vectorEmbeddingPolicies.modelDimensionRangeError, {
max: modelDimensionLimit,
});
}
return error;
};
@@ -137,7 +173,12 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
quantizerType: supportsQuantizer ? matchingIndex?.quantizerType || "product" : undefined,
vectorIndexShardKey: matchingIndex?.vectorIndexShardKey || undefined,
pathError: onVectorEmbeddingPathError(embedding.path),
dimensionsError: onVectorEmbeddingDimensionError(embedding.dimensions, matchingIndex?.type || "none"),
dataTypeError: onVectorEmbeddingDataTypeError(embedding.dataType, embedding.embeddingSource),
dimensionsError: onVectorEmbeddingDimensionError(
embedding.dimensions,
matchingIndex?.type || "none",
embedding.embeddingSource,
),
embeddingSource: embedding.embeddingSource,
embeddingSourceValid: true,
});
@@ -192,7 +233,10 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
);
const validationPassed = vectorEmbeddingPolicyData.every(
(policy: VectorEmbeddingPolicyData) =>
policy.pathError === "" && policy.dimensionsError === "" && policy.embeddingSourceValid,
policy.pathError === "" &&
policy.dataTypeError === "" &&
policy.dimensionsError === "" &&
policy.embeddingSourceValid,
);
onVectorEmbeddingChange(vectorEmbeddings, vectorIndexes, validationPassed);
@@ -216,7 +260,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].dimensions = value;
const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType);
const error = onVectorEmbeddingDimensionError(value, vectorEmbedding.indexType, vectorEmbedding.embeddingSource);
vectorEmbeddings[index].dimensionsError = error;
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
@@ -225,7 +269,11 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
const vectorEmbedding = vectorEmbeddings[index];
vectorEmbeddings[index].indexType = option.key as never;
const error = onVectorEmbeddingDimensionError(vectorEmbedding.dimensions, vectorEmbedding.indexType);
const error = onVectorEmbeddingDimensionError(
vectorEmbedding.dimensions,
vectorEmbedding.indexType,
vectorEmbedding.embeddingSource,
);
vectorEmbeddings[index].dimensionsError = error;
if (vectorEmbedding.indexType === "diskANN") {
vectorEmbedding.indexingSearchListSize = 100;
@@ -280,6 +328,12 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
): void => {
const vectorEmbeddings = [...vectorEmbeddingPolicyData];
vectorEmbeddings[index][property] = option.key as never;
if (property === "dataType") {
vectorEmbeddings[index].dataTypeError = onVectorEmbeddingDataTypeError(
vectorEmbeddings[index].dataType,
vectorEmbeddings[index].embeddingSource,
);
}
setVectorEmbeddingPolicyData(vectorEmbeddings);
};
@@ -294,7 +348,15 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
return prev;
}
const next = [...prev];
next[index] = { ...current, embeddingSource, embeddingSourceValid: isValid };
const dataType = embeddingSourceSupportedDataTypes.includes(current.dataType) ? current.dataType : "float32";
next[index] = {
...current,
dataType,
dataTypeError: onVectorEmbeddingDataTypeError(dataType, embeddingSource),
dimensionsError: onVectorEmbeddingDimensionError(current.dimensions, current.indexType, embeddingSource),
embeddingSource,
embeddingSourceValid: isValid,
};
return next;
});
},
@@ -311,6 +373,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
distanceFunction: "euclidean",
dimensions: 0,
indexType: "none",
dataTypeError: "",
pathError: onVectorEmbeddingPathError(""),
dimensionsError: onVectorEmbeddingDimensionError(0, "none"),
embeddingSource: undefined,
@@ -381,11 +444,12 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
disabled={isExistingPolicy(vectorEmbeddingPolicy)}
required={true}
styles={dropdownStyles}
options={getDataTypeOptions()}
options={getDataTypeOptions(!!vectorEmbeddingPolicy.embeddingSource)}
selectedKey={vectorEmbeddingPolicy.dataType}
onChange={(_event: React.FormEvent<HTMLDivElement>, option: IDropdownOption) =>
onVectorEmbeddingPolicyChange(index, option, "dataType")
}
errorMessage={vectorEmbeddingPolicy.dataTypeError}
></Dropdown>
</Stack>
<Stack>
@@ -529,6 +593,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent<IVectorEmbeddin
{isIntegratedEmbeddingEnabled() && (
<VectorEmbeddingSourceComponent
index={index}
vectorPath={vectorEmbeddingPolicy.path}
disabled={isExistingPolicy(vectorEmbeddingPolicy)}
initialEmbeddingSource={vectorEmbeddingPolicy.embeddingSource}
discardChanges={discardChanges}
@@ -3,7 +3,7 @@ import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/
import { VectorEmbeddingSource } from "Contracts/DataModels";
import {
getAuthTypeOptions,
isValidHttpsUrl,
isValidFoundryEndpoint,
parseSourcePaths,
} from "Explorer/Controls/VectorSearch/VectorSearchUtils";
import { dropdownStyles, labelStyles, textFieldStyles } from "Explorer/Controls/VectorSearch/vectorSearchStyles";
@@ -13,6 +13,7 @@ import React, { FunctionComponent, useState } from "react";
export interface IVectorEmbeddingSourceComponentProps {
index: number;
vectorPath: string;
disabled: boolean;
initialEmbeddingSource?: VectorEmbeddingSource;
discardChanges?: boolean;
@@ -38,13 +39,19 @@ const EmbeddingSourceLabel = ({ disabled, label, tooltip }: EmbeddingSourceLabel
</Label>
);
const validateSourcePaths = (raw: string): string => {
const validateSourcePaths = (raw: string, vectorPath: string): string => {
const parsed = parseSourcePaths(raw);
if (parsed.length === 0) {
return t(Keys.controls.vectorEmbeddingPolicies.sourcePathsRequiredError);
}
const seen = new Set<string>();
for (const p of parsed) {
if (!p.startsWith("/")) {
return t(Keys.controls.vectorEmbeddingPolicies.sourcePathInvalidError);
}
if (p === vectorPath) {
return t(Keys.controls.vectorEmbeddingPolicies.sourcePathSameAsVectorPathError);
}
if (seen.has(p)) {
return t(Keys.controls.vectorEmbeddingPolicies.sourcePathDuplicateError);
}
@@ -64,7 +71,7 @@ const validateEndpoint = (value: string | undefined): string => {
if (!value || value.trim().length === 0) {
return t(Keys.controls.vectorEmbeddingPolicies.endpointRequiredError);
}
if (!isValidHttpsUrl(value.trim())) {
if (!isValidFoundryEndpoint(value.trim())) {
return t(Keys.controls.vectorEmbeddingPolicies.endpointInvalidError);
}
return "";
@@ -72,6 +79,7 @@ const validateEndpoint = (value: string | undefined): string => {
export const VectorEmbeddingSourceComponent: FunctionComponent<IVectorEmbeddingSourceComponentProps> = ({
index,
vectorPath,
disabled,
initialEmbeddingSource,
discardChanges,
@@ -93,7 +101,7 @@ export const VectorEmbeddingSourceComponent: FunctionComponent<IVectorEmbeddingS
modelName.trim().length > 0 ||
endpoint.trim().length > 0;
const sourcePathsError = hasAnyValue ? validateSourcePaths(sourcePathsRaw) : "";
const sourcePathsError = hasAnyValue ? validateSourcePaths(sourcePathsRaw, vectorPath) : "";
const deploymentNameError = hasAnyValue
? validateRequired(deploymentName, Keys.controls.vectorEmbeddingPolicies.deploymentNameRequiredError)
: "";
@@ -196,11 +204,11 @@ export const VectorEmbeddingSourceComponent: FunctionComponent<IVectorEmbeddingS
/>
</Stack>
<Stack>
<Label disabled={disabled} styles={labelStyles}>
<Label disabled={false} styles={labelStyles}>
{t(Keys.controls.vectorEmbeddingPolicies.endpoint)}
</Label>
<TextField
disabled={disabled}
disabled={false}
id={`vector-policy-embeddingSource-endpoint-${suffix}`}
data-test={`VectorEmbeddingSource/Endpoint/${suffix}`}
placeholder={t(Keys.controls.vectorEmbeddingPolicies.endpointPlaceholder)}
@@ -3,11 +3,13 @@ import { VectorEmbeddingSource, VectorIndex } from "Contracts/DataModels";
import { Keys, t } from "Localization";
const dataTypes = ["float32", "uint8", "int8", "float16"];
const embeddingSourceDataTypes = ["float32", "float16"];
const distanceFunctions = ["euclidean", "cosine", "dotproduct"];
const indexTypes = ["none", "flat", "diskANN", "quantizedFlat"];
const authTypes: VectorEmbeddingSource["authType"][] = ["Entra"];
export const getDataTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(dataTypes);
export const getDataTypeOptions = (hasEmbeddingSource = false): IDropdownOption[] =>
createDropdownOptionsFromLiterals(hasEmbeddingSource ? embeddingSourceDataTypes : dataTypes);
export const getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions);
export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes);
export const getAuthTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(authTypes);
@@ -26,14 +28,14 @@ export const parseSourcePaths = (raw: string): string[] => {
return raw
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0)
.map((p) => (p.startsWith("/") ? p : `/${p}`));
.filter((p) => p.length > 0);
};
export const isValidHttpsUrl = (value: string): boolean => {
export const isValidFoundryEndpoint = (value: string): boolean => {
try {
const url = new URL(value);
return url.protocol === "https:";
const allowedHostSuffixes = [".openai.azure.com", ".openai.azure.us", ".openai.azure.cn", ".services.ai.azure.com"];
return url.protocol === "https:" && allowedHostSuffixes.some((suffix) => url.hostname.endsWith(suffix));
} catch {
return false;
}
+8 -3
View File
@@ -1014,14 +1014,19 @@
"pathDuplicateError": "Path is already defined",
"dimensionRangeError": "Dimension must be greater than 0 and less than or equal 4096",
"dimensionFlatIndexError": "Maximum allowed dimension for flat index is 505",
"modelDimensionRangeError": "Dimension must be greater than 0 and less than or equal {{max}}",
"adaDimensionError": "Dimensions must be 1536",
"quantizationByteSizeRangeError": "Quantization byte size must be greater than 0 and less than or equal to 512",
"indexingSearchListSizeRangeError": "Indexing search list size must be greater than or equal to 25 and less than or equal to 500",
"sourcePathsRequiredError": "At least one source path is required",
"sourcePathInvalidError": "Source paths must start with /",
"sourcePathDuplicateError": "Source paths must be unique",
"sourcePathSameAsVectorPathError": "Source path must be different from vector path",
"deploymentNameRequiredError": "Deployment name is required",
"modelNameRequiredError": "Model name is required",
"endpointRequiredError": "Endpoint is required",
"endpointInvalidError": "Endpoint must be a valid https:// URL"
"modelNameRequiredError": "Embedding model name is required",
"endpointRequiredError": "Microsoft Foundry Endpoint is required",
"endpointInvalidError": "Endpoint must be a valid Azure OpenAI or Foundry https:// URL",
"embeddingSourceDataTypeError": "Embedding generation supports only float32 or float16 data types"
}
},
"containerCopy": {