diff --git a/src/Contracts/DataModels.ts b/src/Contracts/DataModels.ts index 770ee7948..78f6ef7d7 100644 --- a/src/Contracts/DataModels.ts +++ b/src/Contracts/DataModels.ts @@ -66,6 +66,8 @@ export interface DatabaseAccountExtendedProperties { enablePriorityBasedExecution?: boolean; vcoreMongoEndpoint?: string; enableAllVersionsAndDeletesChangeFeed?: boolean; + enableFullFidelityChangeFeed?: boolean; + enableEmbeddingGenerator?: boolean; } export interface DatabaseAccountResponseLocation { @@ -414,6 +416,7 @@ export interface AccountOverride { capacityMode?: CapacityMode; enableFreeTier?: boolean; enableAnalyticalStorage?: boolean; + enableEmbeddingGenerator?: boolean; } export interface CreateDatabaseParams { @@ -458,6 +461,15 @@ export interface VectorEmbedding { dimensions: number; distanceFunction: "euclidean" | "cosine" | "dotproduct"; path: string; + embeddingSource?: VectorEmbeddingSource; +} + +export interface VectorEmbeddingSource { + sourcePaths: string[]; + deploymentName: string; + modelName: string; + endpoint: string; + authType: "Entra"; } export interface FullTextPolicy { diff --git a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx index 3dcea02ac..36058345f 100644 --- a/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx +++ b/src/Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent.tsx @@ -13,6 +13,7 @@ export interface CollapsibleSectionProps { onDelete?: () => void; disabled?: boolean; disableDelete?: boolean; + dataTest?: string; } export interface CollapsibleSectionState { @@ -57,6 +58,7 @@ export class CollapsibleSectionComponent extends React.Component diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx index e8e42fb26..edfb946bd 100644 --- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.test.tsx @@ -2,8 +2,14 @@ import "@testing-library/jest-dom"; import { RenderResult, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { VectorEmbedding, VectorIndex } from "Contracts/DataModels"; import React from "react"; +import * as CapabilityUtils from "Utils/CapabilityUtils"; import { VectorEmbeddingPoliciesComponent } from "./VectorEmbeddingPoliciesComponent"; +// The embedding-source UI is gated behind the integrated-embedding capability. +// Default the mock to `true` for the existing suites so they exercise the full UI; +// the dedicated suite at the bottom of this file flips it to `false` to verify gating. +jest.spyOn(CapabilityUtils, "isIntegratedEmbeddingEnabled").mockReturnValue(true); + const mockVectorEmbedding: VectorEmbedding[] = [ { path: "/vector1", dataType: "float32", distanceFunction: "euclidean", dimensions: 0 }, ]; @@ -81,3 +87,197 @@ describe("AddVectorEmbeddingPolicyForm", () => { }); }); }); + +describe("VectorEmbeddingPoliciesComponent - embedding source", () => { + const newEmbedding: VectorEmbedding[] = [ + { path: "/vector2", dataType: "float32", distanceFunction: "cosine", dimensions: 1536 }, + ]; + let onChange: jest.Mock; + let view: RenderResult; + + beforeEach(() => { + onChange = jest.fn(); + view = render( + , + ); + }); + + const expandSection = () => { + const header = screen.getByRole("button", { name: /Embedding source/ }); + fireEvent.click(header); + }; + + test("renders the embedding source accordion collapsed by default with no errors", () => { + expect(screen.getByText("Embedding source (Preview)")).toBeInTheDocument(); + expect(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).toBeNull(); + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[2]).toBe(true); + expect(lastCall[0][0].embeddingSource).toBeUndefined(); + }); + + test("expanding the accordion reveals all source fields without errors when empty", () => { + expandSection(); + expect(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).not.toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1")).not.toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-modelName-1")).not.toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1")).not.toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-authType-1")).not.toBeNull(); + expect(screen.queryByText("At least one source path is required")).toBeNull(); + expect(screen.queryByText("Deployment name is required")).toBeNull(); + }); + + test("typing in one field surfaces required errors for the others", async () => { + expandSection(); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), { + target: { value: "my-deployment" }, + }); + 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(); + }); + const last = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(last[2]).toBe(false); + }); + + test("invalid endpoint shows the https:// 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()); + + 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()); + }); + + 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" }, + }); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), { + target: { value: "my-deployment" }, + }); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-modelName-1"), { + target: { value: "text-embedding-3-small" }, + }); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), { + target: { value: "https://my-foundry.openai.azure.com" }, + }); + + await waitFor(() => { + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + const [embeddings, , valid] = lastCall; + expect(valid).toBe(true); + expect(embeddings[0].embeddingSource).toEqual({ + sourcePaths: ["/description", "/title"], + deploymentName: "my-deployment", + modelName: "text-embedding-3-small", + endpoint: "https://my-foundry.openai.azure.com", + authType: "Entra", + }); + }); + }); + + test("clearing all fields drops embeddingSource from the emitted embedding", async () => { + expandSection(); + const sourcePaths = view.container.querySelector( + "#vector-policy-embeddingSource-sourcePaths-1", + ) as HTMLInputElement; + const deploymentName = view.container.querySelector( + "#vector-policy-embeddingSource-deploymentName-1", + ) as HTMLInputElement; + const modelName = view.container.querySelector("#vector-policy-embeddingSource-modelName-1") as HTMLInputElement; + const endpoint = view.container.querySelector("#vector-policy-embeddingSource-endpoint-1") as HTMLInputElement; + + 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" } }); + + await waitFor(() => { + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[2]).toBe(true); + expect(lastCall[0][0].embeddingSource).toBeDefined(); + }); + + fireEvent.change(sourcePaths, { target: { value: "" } }); + fireEvent.change(deploymentName, { target: { value: "" } }); + fireEvent.change(modelName, { target: { value: "" } }); + fireEvent.change(endpoint, { target: { value: "" } }); + + await waitFor(() => { + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + const [embeddings, , valid] = lastCall; + expect(valid).toBe(true); + expect(embeddings[0].embeddingSource).toBeUndefined(); + }); + }); + + test("does not loop onVectorEmbeddingChange once all fields become valid (regression)", async () => { + // Regression for an infinite render loop where the child rebuilt a fresh + // VectorEmbeddingSource literal on every render, defeating the parent's + // reference-equality dedupe and re-triggering child useEffect via the + // unstable onChange prop. Before the fix, this scenario hung the test + // runner (Jest would time out after several minutes). + expandSection(); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1"), { + target: { value: "/title, /description" }, + }); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1"), { + target: { value: "text-embedding-ada-002" }, + }); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-modelName-1"), { + target: { value: "text-embedding-ada-002" }, + }); + fireEvent.change(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1"), { + target: { value: "https://my-foundry.openai.azure.com" }, + }); + + await waitFor(() => { + const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1]; + expect(lastCall[2]).toBe(true); + expect(lastCall[0][0].embeddingSource).toBeDefined(); + }); + + const stable = onChange.mock.calls.length; + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(onChange.mock.calls.length).toBe(stable); + }); +}); + +describe("VectorEmbeddingPoliciesComponent - embedding source gating", () => { + const newEmbedding: VectorEmbedding[] = [ + { path: "/vector3", dataType: "float32", distanceFunction: "cosine", dimensions: 1536 }, + ]; + + test("hides the embedding source accordion when the integrated-embedding capability is missing", () => { + const isEnabledSpy = jest.spyOn(CapabilityUtils, "isIntegratedEmbeddingEnabled").mockReturnValue(false); + try { + const view = render( + , + ); + expect(screen.queryByText("Embedding source (Preview)")).toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-sourcePaths-1")).toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-deploymentName-1")).toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-modelName-1")).toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-endpoint-1")).toBeNull(); + expect(view.container.querySelector("#vector-policy-embeddingSource-authType-1")).toBeNull(); + } finally { + isEnabledSpy.mockReturnValue(true); + } + }); +}); diff --git a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx index 88a6f021b..62da81074 100644 --- a/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx +++ b/src/Explorer/Controls/VectorSearch/VectorEmbeddingPoliciesComponent.tsx @@ -1,17 +1,8 @@ -import { - DefaultButton, - Dropdown, - IDropdownOption, - IStyleFunctionOrObject, - ITextFieldStyleProps, - ITextFieldStyles, - Label, - Stack, - TextField, -} from "@fluentui/react"; +import { DefaultButton, Dropdown, IDropdownOption, Label, Stack, TextField } from "@fluentui/react"; import { InfoTooltip } from "Common/Tooltip/InfoTooltip"; -import { VectorEmbedding, VectorIndex } from "Contracts/DataModels"; +import { VectorEmbedding, VectorEmbeddingSource, VectorIndex } from "Contracts/DataModels"; import { CollapsibleSectionComponent } from "Explorer/Controls/CollapsiblePanel/CollapsibleSectionComponent"; +import { VectorEmbeddingSourceComponent } from "Explorer/Controls/VectorSearch/VectorEmbeddingSourceComponent"; import { getDataTypeOptions, getDistanceFunctionOptions, @@ -19,8 +10,15 @@ import { getQuantizerTypeOptions, supportsQuantization, } from "Explorer/Controls/VectorSearch/VectorSearchUtils"; +import { dropdownStyles, labelStyles, textFieldStyles } from "Explorer/Controls/VectorSearch/vectorSearchStyles"; import { Keys, t } from "Localization"; -import React, { FunctionComponent, useState } from "react"; +import React, { FunctionComponent, useCallback, useState } from "react"; +import { isIntegratedEmbeddingEnabled } from "Utils/CapabilityUtils"; + +const generatePolicyId = (): string => + typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `vep-${Date.now()}-${Math.random().toString(36).slice(2)}`; export interface IVectorEmbeddingPoliciesComponentProps { vectorEmbeddingsBaseline: VectorEmbedding[]; @@ -37,6 +35,7 @@ export interface IVectorEmbeddingPoliciesComponentProps { } export interface VectorEmbeddingPolicyData { + id: string; path: string; dataType: VectorEmbedding["dataType"]; distanceFunction: VectorEmbedding["distanceFunction"]; @@ -50,44 +49,12 @@ export interface VectorEmbeddingPolicyData { quantizationByteSize?: number; quantizationByteSizeError?: string; quantizerType?: VectorIndex["quantizerType"]; + embeddingSource?: VectorEmbeddingSource; + embeddingSourceValid: boolean; } type VectorEmbeddingPolicyProperty = "dataType" | "distanceFunction" | "indexType"; -const labelStyles = { - root: { - fontSize: 12, - color: "var(--colorNeutralForeground1)", - }, -}; - -const textFieldStyles: IStyleFunctionOrObject = { - fieldGroup: { - height: 27, - }, - field: { - fontSize: 12, - padding: "0 8px", - backgroundColor: "var(--colorNeutralBackground1)", - color: "var(--colorNeutralForeground1)", - }, -}; - -const dropdownStyles = { - title: { - height: 27, - lineHeight: "24px", - fontSize: 12, - }, - dropdown: { - height: 27, - lineHeight: "24px", - }, - dropdownItem: { - fontSize: 12, - }, -}; - export const VectorEmbeddingPoliciesComponent: FunctionComponent = ({ vectorEmbeddingsBaseline, vectorEmbeddings, @@ -163,6 +130,7 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent { - const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => ({ - path: policy.path, - dataType: policy.dataType, - dimensions: policy.dimensions, - distanceFunction: policy.distanceFunction, - })); + const vectorEmbeddings: VectorEmbedding[] = vectorEmbeddingPolicyData.map((policy: VectorEmbeddingPolicyData) => { + const base: VectorEmbedding = { + path: policy.path, + dataType: policy.dataType, + dimensions: policy.dimensions, + distanceFunction: policy.distanceFunction, + }; + if (policy.embeddingSource) { + base.embeddingSource = policy.embeddingSource; + } + return base; + }); const vectorIndexes: VectorIndex[] = vectorEmbeddingPolicyData .filter((policy: VectorEmbeddingPolicyData) => policy.indexType !== "none") .map( @@ -215,7 +191,8 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent policy.pathError === "" && policy.dimensionsError === "", + (policy: VectorEmbeddingPolicyData) => + policy.pathError === "" && policy.dimensionsError === "" && policy.embeddingSourceValid, ); onVectorEmbeddingChange(vectorEmbeddings, vectorIndexes, validationPassed); @@ -306,10 +283,29 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent { + setVectorEmbeddingPolicyData((prev) => { + const current = prev[index]; + if (!current) { + return prev; + } + if (current.embeddingSource === embeddingSource && current.embeddingSourceValid === isValid) { + return prev; + } + const next = [...prev]; + next[index] = { ...current, embeddingSource, embeddingSourceValid: isValid }; + return next; + }); + }, + [], + ); + const onAdd = () => { setVectorEmbeddingPolicyData([ ...vectorEmbeddingPolicyData, { + id: generatePolicyId(), path: "", dataType: "float32", distanceFunction: "euclidean", @@ -317,6 +313,8 @@ export const VectorEmbeddingPoliciesComponent: FunctionComponent ( onDelete(index)} disableDelete={false} + dataTest={`VectorEmbedding/Section/${index + 1}`} > )} + {isIntegratedEmbeddingEnabled() && ( + onEmbeddingSourceChange(index, source, isValid)} + /> + )} ))} - + {t(Keys.controls.vectorEmbeddingPolicies.addVectorEmbedding)} diff --git a/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts b/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts index 7e90053fc..9db70a133 100644 --- a/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts +++ b/src/Explorer/Controls/VectorSearch/VectorSearchUtils.ts @@ -1,14 +1,16 @@ import { IDropdownOption } from "@fluentui/react"; -import { VectorIndex } from "Contracts/DataModels"; +import { VectorEmbeddingSource, VectorIndex } from "Contracts/DataModels"; import { Keys, t } from "Localization"; const dataTypes = ["float32", "uint8", "int8", "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 getDistanceFunctionOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(distanceFunctions); export const getIndexTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(indexTypes); +export const getAuthTypeOptions = (): IDropdownOption[] => createDropdownOptionsFromLiterals(authTypes); export const getQuantizerTypeOptions = (): IDropdownOption[] => [ { key: "product", text: "Product" }, { key: "spherical", text: `Spherical (${t(Keys.common.preview)})` }, @@ -17,6 +19,28 @@ export const getQuantizerTypeOptions = (): IDropdownOption[] => [ export const supportsQuantization = (indexType: VectorIndex["type"] | "none" | undefined): boolean => indexType === "quantizedFlat" || indexType === "diskANN"; +// Parses a comma-separated path list, trims whitespace, drops blanks, and +// prefixes a leading "/" on each entry if it isn't there already. +export const parseSourcePaths = (raw: string): string[] => { + if (!raw) { + return []; + } + return raw + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0) + .map((p) => (p.startsWith("/") ? p : `/${p}`)); +}; + +export const isValidHttpsUrl = (value: string): boolean => { + try { + const url = new URL(value); + return url.protocol === "https:"; + } catch { + return false; + } +}; + function createDropdownOptionsFromLiterals(literals: T[]): IDropdownOption[] { return literals.map((value) => ({ key: value, diff --git a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx index 52ec53371..4cb52802a 100644 --- a/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx +++ b/src/Explorer/Panes/AddCollectionPanel/AddCollectionPanel.tsx @@ -838,6 +838,7 @@ export class AddCollectionPanel extends React.Component diff --git a/src/Localization/en/Resources.json b/src/Localization/en/Resources.json index 6ac748936..3a6c37927 100644 --- a/src/Localization/en/Resources.json +++ b/src/Localization/en/Resources.json @@ -994,12 +994,27 @@ "indexingSearchListSize": "Indexing search list size", "vectorIndexShardKey": "Vector index shard key", "addVectorEmbedding": "Add vector embedding", + "embeddingSourceSection": "Embedding source", + "embeddingSourceTooltip": "Configure Cosmos DB to auto-generate the vector from one or more source paths using a Microsoft Foundry deployment. Leave all fields empty to skip auto-generation.", + "sourcePaths": "Source paths", + "sourcePathsPlaceholder": "/description, /title", + "deploymentName": "Deployment name", + "modelName": "Model name", + "endpoint": "Endpoint", + "endpointPlaceholder": "https://", + "authType": "Authentication type", "pathEmptyError": "Path should not be empty", "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", "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" + "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", + "sourcePathDuplicateError": "Source paths must be unique", + "deploymentNameRequiredError": "Deployment name is required", + "modelNameRequiredError": "Model name is required", + "endpointRequiredError": "Endpoint is required", + "endpointInvalidError": "Endpoint must be a valid https:// URL" } }, "containerCopy": { @@ -1173,4 +1188,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/Utils/CapabilityUtils.ts b/src/Utils/CapabilityUtils.ts index de341bb13..23955e6ac 100644 --- a/src/Utils/CapabilityUtils.ts +++ b/src/Utils/CapabilityUtils.ts @@ -34,3 +34,14 @@ export const isFullTextSearchPreviewFeaturesEnabled = (targetAccountOverride?: A isCapabilityEnabled(Constants.CapabilityNames.EnableNoSQLFullTextSearchPreviewFeatures, targetAccountOverride) ); }; + +// `enableEmbeddingGenerator` is a top-level boolean on the database account +// (e.g. properties.enableEmbeddingGenerator), not an entry in +// properties.capabilities[]. It's the Foundry integrated embedding generator +// flag that gates the `embeddingSource` block inside a Container Vector Policy. +export const isIntegratedEmbeddingEnabled = (targetAccountOverride?: AccountOverride): boolean => { + const { databaseAccount } = userContext; + const enableEmbeddingGenerator = + targetAccountOverride?.enableEmbeddingGenerator ?? databaseAccount?.properties?.enableEmbeddingGenerator; + return isVectorSearchEnabled(targetAccountOverride) && enableEmbeddingGenerator === true; +}; diff --git a/src/Utils/arm/generatedClients/cosmos/types.ts b/src/Utils/arm/generatedClients/cosmos/types.ts index 0e74697d2..70d7df0a1 100644 --- a/src/Utils/arm/generatedClients/cosmos/types.ts +++ b/src/Utils/arm/generatedClients/cosmos/types.ts @@ -1412,6 +1412,27 @@ export interface VectorEmbedding { /* The number of dimensions in the vector. */ dimensions: number; + + /* Optional configuration for Cosmos DB integrated embeddings (server-side + embedding generation via Microsoft Foundry). When set, Cosmos DB + auto-populates the vector field from the listed source paths. + TODO: This field was added manually pending an upstream Swagger update; + re-applying `npm run generateARMClients` will overwrite it. */ + embeddingSource?: VectorEmbeddingSource; +} + +/* Configuration for Cosmos DB integrated embeddings. */ +export interface VectorEmbeddingSource { + /* Source field paths used as input for embedding generation. */ + sourcePaths: string[]; + /* The Foundry deployment name. */ + deploymentName: string; + /* The Foundry model name. */ + modelName: string; + /* The Foundry endpoint URL. */ + endpoint: string; + /* Authentication type used by Cosmos DB to call Foundry. */ + authType: "Entra"; } /* Represents the full text index path. */ diff --git a/test/sql/container.spec.ts b/test/sql/container.spec.ts index ea02b2105..129a8d51c 100644 --- a/test/sql/container.spec.ts +++ b/test/sql/container.spec.ts @@ -50,3 +50,81 @@ test("SQL database and container CRUD", async ({ page }) => { await expect(databaseNode.element).not.toBeAttached(); }); + +test("SQL container with vector embedding policy and embedding source", async ({ page }) => { + const databaseId = generateUniqueName("vdb"); + const containerId = "testvectorcontainer"; + + const explorer = await DataExplorer.open(page, TestAccount.SQL); + + // Open the New Container panel and check if the embedding source capability is available + // before proceeding. We must skip before whilePanelOpen to avoid a timeout on panel close. + const newContainerButton = await explorer.globalCommandButton("New Container"); + await newContainerButton.click(); + + const panel = explorer.panel("New Container"); + await panel.waitFor(); + + // Expand vector policy section and add a vector embedding to check for the embedding source accordion + await panel.getByTestId("ContainerVectorPolicy/Section").click(); + await panel.getByTestId("VectorEmbedding/AddButton").click(); + + const embeddingSourceSection = panel.getByTestId("VectorEmbeddingSource/Section/1"); + if ((await embeddingSourceSection.count()) === 0) { + // Close the panel before skipping + await panel.getByRole("button", { name: "Close New Container" }).click(); + await panel.waitFor({ state: "detached" }); + test.skip(true, "Test account does not have the integrated embedding capability."); + } + + await panel.getByPlaceholder("Type a new database id").fill(databaseId); + await panel.getByRole("textbox", { name: "Container id, Example Container1" }).fill(containerId); + await panel.getByRole("textbox", { name: "Partition key" }).fill("/pk"); + await panel.getByTestId("autoscaleRUInput").fill(TEST_AUTOSCALE_THROUGHPUT_RU.toString()); + + await panel.getByTestId("VectorEmbedding/Path/1").fill("/embedding"); + await panel.getByTestId("VectorEmbedding/Dimensions/1").fill("1536"); + + // Expand embedding source section and fill fields + await embeddingSourceSection.click(); + + await panel.getByTestId("VectorEmbeddingSource/SourcePaths/1").fill("/description"); + await panel.getByTestId("VectorEmbeddingSource/DeploymentName/1").fill("text-embedding-3-small"); + await panel.getByTestId("VectorEmbeddingSource/ModelName/1").fill("text-embedding-3-small"); + await panel + .getByTestId("VectorEmbeddingSource/Endpoint/1") + .fill("https://e2e-embedding.cognitiveservices.azure.com/"); + + const okButton = panel.getByTestId("Panel/OkButton"); + await okButton.click(); + await panel.waitFor({ state: "detached", timeout: 5 * 60 * 1000 }); + + const databaseNode = await explorer.waitForNode(databaseId); + const containerNode = await explorer.waitForContainerNode(databaseId, containerId); + + // Cleanup + await containerNode.openContextMenu(); + await containerNode.contextMenuItem("Delete Container").click(); + await explorer.whilePanelOpen( + "Delete Container", + async (panel, okButton) => { + await panel.getByRole("textbox", { name: "Confirm by typing the container id" }).fill(containerId); + await okButton.click(); + }, + { closeTimeout: 5 * 60 * 1000 }, + ); + await expect(containerNode.element).not.toBeAttached(); + + await databaseNode.openContextMenu(); + await databaseNode.contextMenuItem("Delete Database").click(); + await explorer.whilePanelOpen( + "Delete Database", + async (panel, okButton) => { + await panel.getByRole("textbox", { name: "Confirm by typing the database id" }).fill(databaseId); + await okButton.click(); + }, + { closeTimeout: 5 * 60 * 1000 }, + ); + + await expect(databaseNode.element).not.toBeAttached(); +}); diff --git a/test/sql/scaleAndSettings/containerPolicies/vectorPolicy.spec.ts b/test/sql/scaleAndSettings/containerPolicies/vectorPolicy.spec.ts index f1fd4a39a..db2614e8e 100644 --- a/test/sql/scaleAndSettings/containerPolicies/vectorPolicy.spec.ts +++ b/test/sql/scaleAndSettings/containerPolicies/vectorPolicy.spec.ts @@ -190,4 +190,59 @@ test.describe("Vector Policy under Scale & Settings", () => { const saveButton = explorer.commandBarButton(CommandBarButton.Save); await expect(saveButton).toBeDisabled(); }); + + test("Add embedding source to vector embedding policy", async () => { + const addButton = explorer.frame.getByTestId("VectorEmbedding/AddButton"); + await addButton.click(); + + const embeddingSourceSection = explorer.frame.getByTestId("VectorEmbeddingSource/Section/1"); + if ((await embeddingSourceSection.count()) === 0) { + test.skip(true, "Test account does not have the integrated embedding capability."); + } + + await explorer.frame.getByTestId("VectorEmbedding/Path/1").fill("/embedding"); + await explorer.frame.getByTestId("VectorEmbedding/Dimensions/1").fill("1536"); + + // Expand embedding source section and fill fields + await embeddingSourceSection.click(); + + await explorer.frame.getByTestId("VectorEmbeddingSource/SourcePaths/1").fill("/description"); + await explorer.frame.getByTestId("VectorEmbeddingSource/DeploymentName/1").fill("text-embedding-3-small"); + await explorer.frame.getByTestId("VectorEmbeddingSource/ModelName/1").fill("text-embedding-3-small"); + await explorer.frame + .getByTestId("VectorEmbeddingSource/Endpoint/1") + .fill("https://e2e-embedding.cognitiveservices.azure.com/"); + + // Save changes + const saveButton = explorer.commandBarButton(CommandBarButton.Save); + await expect(saveButton).toBeEnabled(); + await saveButton.click(); + await expect(explorer.getConsoleHeaderStatus()).toContainText( + `Successfully updated container ${context.container.id}`, + { timeout: 2 * ONE_MINUTE_MS }, + ); + }); + + test("Existing embedding source fields are disabled after save", async () => { + // Ensure a policy with embedding source exists (from previous test or create one) + const sourcePathsInput = explorer.frame.getByTestId("VectorEmbeddingSource/SourcePaths/1"); + if ((await sourcePathsInput.count()) === 0) { + test.skip(true, "No embedding source present; previous test may have been skipped."); + } + + await expect(sourcePathsInput).toBeDisabled(); + await expect(sourcePathsInput).toHaveValue("/description"); + + const deploymentInput = explorer.frame.getByTestId("VectorEmbeddingSource/DeploymentName/1"); + await expect(deploymentInput).toBeDisabled(); + await expect(deploymentInput).toHaveValue("text-embedding-3-small"); + + const modelInput = explorer.frame.getByTestId("VectorEmbeddingSource/ModelName/1"); + await expect(modelInput).toBeDisabled(); + await expect(modelInput).toHaveValue("text-embedding-3-small"); + + const endpointInput = explorer.frame.getByTestId("VectorEmbeddingSource/Endpoint/1"); + await expect(endpointInput).toBeDisabled(); + await expect(endpointInput).toHaveValue("https://e2e-embedding.cognitiveservices.azure.com/"); + }); });