diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx index c08571cfc..421ca0d88 100644 --- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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", () => { diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx index 62da81074..44b85d0e1 100644 --- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx @@ -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 = ({ vectorEmbeddingsBaseline, @@ -95,7 +108,21 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent { + 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 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 - 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 { 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, option: IDropdownOption) => onVectorEmbeddingPolicyChange(index, option, "dataType") } + errorMessage={vectorEmbeddingPolicy.dataTypeError} > @@ -529,6 +593,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent ); -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(); 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 = ({ index, + vectorPath, disabled, initialEmbeddingSource, discardChanges, @@ -93,7 +101,7 @@ export const VectorEmbeddingSourceComponent: FunctionComponent 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 -