diff --git a/python/.env.example b/python/.env.example index 6811c4673ee..b37b1787935 100644 --- a/python/.env.example +++ b/python/.env.example @@ -1,13 +1,15 @@ # Microsoft Foundry +# Project endpoint used by FoundryChatClient and OpenAI deployments in FoundryEmbeddingClient FOUNDRY_PROJECT_ENDPOINT="" # Model used for FoundryChatClient FOUNDRY_MODEL="" # Foundry Agents (prompt or hosted agents) FOUNDRY_AGENT_NAME="" FOUNDRY_AGENT_VERSION="" -# Microsoft Foundry Models endpoint, used by embeddings +# Microsoft Foundry Models inference endpoint, used by image and non-project embeddings FOUNDRY_MODELS_ENDPOINT="" FOUNDRY_MODELS_API_KEY="" +# Model used by FoundryEmbeddingClient FOUNDRY_EMBEDDING_MODEL="" FOUNDRY_IMAGE_EMBEDDING_MODEL="" # Bing connection for web search (optional, used by samples with web search) diff --git a/python/packages/foundry/README.md b/python/packages/foundry/README.md index 94046a9ed36..d605962e477 100644 --- a/python/packages/foundry/README.md +++ b/python/packages/foundry/README.md @@ -8,6 +8,39 @@ This package supports `azure-ai-projects>=2.2.0,<2.7.0`. Projects 2.5 and later `openai>=3.0.0`, so `agent-framework-foundry` requires `agent-framework-openai>=1.14.2`, which supports both OpenAI 2.x and 3.x. +## Embeddings + +`FoundryEmbeddingClient` supports OpenAI text embedding deployments exposed through a Microsoft Foundry project. +Pass an existing `AIProjectClient`, or provide the project endpoint and an async Azure credential: + +```python +import os + +from agent_framework.foundry import FoundryEmbeddingClient +from azure.identity.aio import AzureCliCredential + +async with AzureCliCredential() as credential: + async with FoundryEmbeddingClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_EMBEDDING_MODEL"], + credential=credential, + ) as client: + result = await client.get_embeddings(["Hello, world!"]) + print(result[0].dimensions) +``` + +Set `FOUNDRY_PROJECT_ENDPOINT` to the project endpoint and `FOUNDRY_EMBEDDING_MODEL` to the embedding deployment +name. When an `AIProjectClient` is already available, pass it as `project_client` and omit the endpoint and +credential. + +The client uses the project for authentication and converts a +`https://.services.ai.azure.com/api/projects/` endpoint to the documented resource-scoped +`https://.openai.azure.com/openai/v1/` model route. The existing `FOUNDRY_MODELS_ENDPOINT` and +`FOUNDRY_MODELS_API_KEY` configuration remains available for Foundry Models inference endpoints. A Models endpoint is +required for image embedding models. If both project and Models endpoints are configured only through environment +variables, the Models endpoint is retained for backward compatibility; pass `project_endpoint` explicitly to select +the project OpenAI deployment. + ## Evaluations `FoundryEvals` implements the provider-neutral `Evaluator` protocol with diff --git a/python/packages/foundry/agent_framework_foundry/_embedding_client.py b/python/packages/foundry/agent_framework_foundry/_embedding_client.py index c5254ad890a..d9f5e3fb8f6 100644 --- a/python/packages/foundry/agent_framework_foundry/_embedding_client.py +++ b/python/packages/foundry/agent_framework_foundry/_embedding_client.py @@ -2,11 +2,14 @@ from __future__ import annotations +import base64 import logging +import struct import sys -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from contextlib import suppress -from typing import Any, ClassVar, Generic, TypedDict +from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict, cast +from urllib.parse import urlsplit, urlunsplit from agent_framework import ( BaseEmbeddingClient, @@ -18,13 +21,22 @@ UsageDetails, load_settings, ) -from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent, mark_feature_used +from agent_framework._telemetry import IS_TELEMETRY_ENABLED, USER_AGENT_KEY, get_user_agent, mark_feature_used from agent_framework.observability import EmbeddingTelemetryLayer from azure.ai.inference.aio import EmbeddingsClient, ImageEmbeddingsClient from azure.ai.inference.models import ImageEmbeddingInput from azure.core.credentials import AzureKeyCredential +from azure.core.credentials_async import AsyncTokenCredential -from ._feature_usage import FeatureIndex, create_feature_usage_policy +from ._feature_usage import ( + FeatureIndex, + create_feature_usage_policy, + create_foundry_feature_usage_http_client, +) + +if TYPE_CHECKING: + from azure.ai.projects.aio import AIProjectClient + from openai import AsyncOpenAI if sys.version_info >= (3, 13): from typing import TypeVar # pragma: no cover @@ -37,10 +49,19 @@ _IMAGE_MEDIA_PREFIXES = ("image/",) +def _get_openai_model_base_url(endpoint: str) -> str: + """Get the documented resource-scoped OpenAI model URL from a Foundry endpoint.""" + parts = urlsplit(endpoint) + if not parts.scheme or not parts.netloc: + raise ValueError(f"Invalid Foundry endpoint: {endpoint!r}") + openai_netloc = parts.netloc.replace(".services.ai.", ".openai.", 1) + return urlunsplit((parts.scheme, openai_netloc, "/openai/v1/", "", "")) + + class FoundryEmbeddingOptions(EmbeddingGenerationOptions, total=False): - """Foundry inference-specific embedding options. + """Foundry-specific embedding options. - Extends ``EmbeddingGenerationOptions`` with Foundry inference-specific fields. + Extends ``EmbeddingGenerationOptions`` with Foundry-specific fields. Examples: .. code-block:: python @@ -81,8 +102,9 @@ class FoundryEmbeddingOptions(EmbeddingGenerationOptions, total=False): class FoundryEmbeddingSettings(TypedDict, total=False): - """Foundry inference embedding settings.""" + """Foundry embedding settings.""" + project_endpoint: str | None models_endpoint: str | None models_api_key: SecretString | None embedding_model: str | None @@ -95,10 +117,10 @@ class RawFoundryEmbeddingClient( ): """Raw Foundry embedding client without telemetry. - Accepts both text (``str``) and image (``Content``) inputs. Text and image - inputs within a single batch are separated and dispatched to - ``EmbeddingsClient`` and ``ImageEmbeddingsClient`` respectively. Results - are reassembled in the original input order. + Text embeddings can be generated through OpenAI model deployments in a + Foundry project or through a Foundry Models inference endpoint. Image + embeddings use the Foundry Models inference endpoint. Results are + reassembled in the original input order. Keyword Args: model: The text embedding model (e.g. "text-embedding-3-small"). @@ -106,37 +128,71 @@ class RawFoundryEmbeddingClient( image_model: The image embedding model (e.g. "Cohere-embed-v3-english"). Can also be set via environment variable FOUNDRY_IMAGE_EMBEDDING_MODEL. Falls back to ``model`` if not provided. + project_endpoint: The Foundry project endpoint URL used for OpenAI + embedding deployments. Can also be set via environment variable + FOUNDRY_PROJECT_ENDPOINT. + project_client: An existing ``AIProjectClient``. If provided, its + OpenAI-compatible client is used for text embeddings. endpoint: The Foundry inference endpoint URL. Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT. api_key: API key for authentication. Can also be set via environment variable FOUNDRY_MODELS_API_KEY. text_client: Optional pre-configured ``EmbeddingsClient``. image_client: Optional pre-configured ``ImageEmbeddingsClient``. - credential: Optional ``AzureKeyCredential`` or token credential. If not provided, - one is created from ``api_key``. + credential: Async Azure credential. Required when using + ``project_endpoint`` without a ``project_client``. For a Foundry + Models endpoint, an ``AzureKeyCredential`` is created from + ``api_key`` when needed. + allow_preview: Enables preview opt-in on an internally created + ``AIProjectClient``. + default_headers: Additional HTTP headers for project OpenAI requests. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. """ + INJECTABLE: ClassVar[set[str]] = {"image_client", "project_client", "text_client"} + def __init__( self, *, model: str | None = None, image_model: str | None = None, endpoint: str | None = None, + project_endpoint: str | None = None, + project_client: AIProjectClient | None = None, api_key: str | SecretString | None = None, text_client: EmbeddingsClient | None = None, image_client: ImageEmbeddingsClient | None = None, - credential: AzureKeyCredential | None = None, + credential: AzureKeyCredential | AsyncTokenCredential | None = None, + allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, env_file_encoding: str | None = None, ) -> None: """Initialize a raw Foundry embedding client.""" + if project_endpoint is not None: + project_endpoint = project_endpoint.strip() or None + if endpoint is not None: + endpoint = endpoint.strip() or None + if (isinstance(api_key, str) and not api_key.strip()) or ( + isinstance(api_key, SecretString) and not api_key.get_secret_value().strip() + ): + api_key = None + + explicit_project_source = project_client is not None or project_endpoint is not None + explicit_inference_source = any(value is not None for value in (endpoint, api_key, text_client, image_client)) + if explicit_project_source and explicit_inference_source: + raise ValueError( + "Foundry project embedding configuration cannot be combined with Foundry Models " + "'endpoint', 'api_key', 'text_client', or 'image_client' configuration." + ) + settings = load_settings( FoundryEmbeddingSettings, env_prefix="FOUNDRY_", - required_fields=["models_endpoint", "embedding_model"], + required_fields=["embedding_model"], + project_endpoint=project_endpoint, models_endpoint=endpoint, models_api_key=api_key, embedding_model=model, @@ -147,37 +203,112 @@ def __init__( self.model = settings["embedding_model"] # type: ignore[reportTypedDictNotRequiredAccess] self.image_model: str = settings.get("image_embedding_model") or self.model # type: ignore[assignment] - resolved_endpoint = settings["models_endpoint"] # type: ignore[reportTypedDictNotRequiredAccess] - - if credential is None and (models_api_key := settings.get("models_api_key")): - credential = AzureKeyCredential(models_api_key.get_secret_value()) - - if credential is None and text_client is None and image_client is None: - raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.") - - client_kwargs: dict[str, Any] = { - "endpoint": resolved_endpoint, - "credential": credential, - } - if IS_TELEMETRY_ENABLED: - client_kwargs["user_agent"] = get_user_agent() - self._text_client = text_client or EmbeddingsClient( - **client_kwargs, - per_retry_policies=[create_feature_usage_policy()], + resolved_models_endpoint = settings.get("models_endpoint") or None + resolved_project_endpoint = settings.get("project_endpoint") or None + use_project_client = explicit_project_source or ( + not explicit_inference_source and resolved_models_endpoint is None and resolved_project_endpoint is not None ) - self._image_client = image_client or ImageEmbeddingsClient( - **client_kwargs, - per_retry_policies=[create_feature_usage_policy()], + + self.project_client: AIProjectClient | None = None + self._owns_project_client = False + self._openai_client: AsyncOpenAI | None = None + self._text_client: EmbeddingsClient | None = None + self._image_client: ImageEmbeddingsClient | None = None + self.default_headers = ( + {key: value for key, value in default_headers.items() if key != USER_AGENT_KEY} if default_headers else None ) - self._endpoint = resolved_endpoint + + if use_project_client: + if text_client is not None or image_client is not None: + raise ValueError( + "'text_client' and 'image_client' cannot be used with 'project_endpoint' or 'project_client'." + ) + if project_client is None: + if not resolved_project_endpoint: + raise ValueError( + "Foundry project endpoint is required. Set via 'project_endpoint' parameter " + "or 'FOUNDRY_PROJECT_ENDPOINT' environment variable." + ) + if credential is None: + raise ValueError( + "Azure credential is required when using project_endpoint without a project_client." + ) + if isinstance(credential, AzureKeyCredential): + raise ValueError("A token credential is required when using a Foundry project endpoint.") + + from azure.ai.projects.aio import AIProjectClient + + project_client_kwargs: dict[str, Any] = { + "endpoint": resolved_project_endpoint, + "credential": credential, + "per_retry_policies": [create_feature_usage_policy()], + } + if IS_TELEMETRY_ENABLED: + project_client_kwargs["user_agent"] = get_user_agent() + if allow_preview is not None: + project_client_kwargs["allow_preview"] = allow_preview + project_client = AIProjectClient(**project_client_kwargs) + self._owns_project_client = True + + openai_kwargs: dict[str, Any] = {} + if default_headers: + openai_kwargs["default_headers"] = default_headers + if self._owns_project_client: + openai_kwargs["http_client"] = create_foundry_feature_usage_http_client() + + self.project_client = project_client + self._openai_client = project_client.get_openai_client(**openai_kwargs) + self._endpoint = _get_openai_model_base_url(str(self._openai_client.base_url)) + self._openai_client.base_url = self._endpoint + else: + if not resolved_models_endpoint: + raise ValueError( + "Either 'project_endpoint', 'project_client', or 'endpoint' is required. " + "Set a Foundry project endpoint via 'FOUNDRY_PROJECT_ENDPOINT' or a Foundry Models " + "endpoint via 'FOUNDRY_MODELS_ENDPOINT'." + ) + + if credential is None and (models_api_key := settings.get("models_api_key")): + credential = AzureKeyCredential(models_api_key.get_secret_value()) + + if credential is None and text_client is None and image_client is None: + raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.") + + client_kwargs: dict[str, Any] = { + "endpoint": resolved_models_endpoint, + "credential": credential, + } + if IS_TELEMETRY_ENABLED: + client_kwargs["user_agent"] = get_user_agent() + self._text_client = text_client + self._image_client = image_client + if credential is not None: + self._text_client = text_client or EmbeddingsClient( + **client_kwargs, + per_retry_policies=[create_feature_usage_policy()], + ) + self._image_client = image_client or ImageEmbeddingsClient( + **client_kwargs, + per_retry_policies=[create_feature_usage_policy()], + ) + self._endpoint = resolved_models_endpoint + super().__init__(additional_properties=additional_properties) async def close(self) -> None: """Close the underlying SDK clients and release resources.""" - with suppress(Exception): - await self._text_client.close() - with suppress(Exception): - await self._image_client.close() + if self._openai_client is not None: + with suppress(Exception): + await self._openai_client.close() + if self._owns_project_client and self.project_client is not None: + with suppress(Exception): + await self.project_client.close() + if self._text_client is not None: + with suppress(Exception): + await self._text_client.close() + if self._image_client is not None: + with suppress(Exception): + await self._image_client.close() async def __aenter__(self) -> RawFoundryEmbeddingClient[FoundryEmbeddingOptionsT]: """Enter the async context manager.""" @@ -263,56 +394,100 @@ async def get_embeddings( embeddings: list[Embedding[list[float]] | None] = [None] * len(values) usage_details: UsageDetails = {} + image_client = self._image_client + if image_items and image_client is None: + raise ValueError( + "Image embeddings require a Foundry Models inference endpoint. " + "Configure 'endpoint' or 'FOUNDRY_MODELS_ENDPOINT' instead of a project endpoint." + ) + image_client = cast(ImageEmbeddingsClient, image_client) + # Embed text inputs. if text_items: if not (text_model := opts.get("model") or self.model): raise ValueError("A model is required, either in the client or options, for text inputs.") text_inputs = [t for _, t in text_items] - response = await self._text_client.embed( - input=text_inputs, - model=text_model, - **common_kwargs, - ) - for i, item in enumerate(response.data): - original_idx = text_items[i][0] - vector: list[float] = [float(v) for v in item.embedding] - embeddings[original_idx] = Embedding( - vector=vector, - dimensions=len(vector), - model=response.model or text_model, - ) - if response.usage: - usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( - response.usage.prompt_tokens or 0 - ) - usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + ( - getattr(response.usage, "completion_tokens", 0) or 0 + if self._openai_client is not None: + openai_kwargs: dict[str, Any] = { + "input": text_inputs, + "model": text_model, + } + if dimensions := opts.get("dimensions"): + openai_kwargs["dimensions"] = dimensions + if encoding_format := opts.get("encoding_format"): + openai_kwargs["encoding_format"] = encoding_format + + extra_body = dict(opts.get("extra_parameters") or {}) + if input_type := opts.get("input_type"): + extra_body["input_type"] = input_type + if extra_body: + openai_kwargs["extra_body"] = extra_body + + openai_response = await self._openai_client.embeddings.create(**openai_kwargs) + encoding = openai_kwargs.get("encoding_format", "float") + for item in sorted(openai_response.data, key=lambda value: value.index): + original_idx = text_items[item.index][0] + if encoding == "base64" and isinstance(item.embedding, str): + raw = base64.b64decode(item.embedding) + vector = list(struct.unpack(f"<{len(raw) // 4}f", raw)) + else: + vector = [float(value) for value in item.embedding] + embeddings[original_idx] = Embedding( + vector=vector, + dimensions=len(vector), + model=openai_response.model or text_model, + ) + if openai_response.usage: + usage_details["input_token_count"] = openai_response.usage.prompt_tokens + usage_details["total_token_count"] = openai_response.usage.total_tokens + elif self._text_client is not None: + inference_response = await self._text_client.embed( + input=text_inputs, + model=text_model, + **common_kwargs, ) + for i, item in enumerate(inference_response.data): + original_idx = text_items[i][0] + vector = [float(value) for value in item.embedding] + embeddings[original_idx] = Embedding( + vector=vector, + dimensions=len(vector), + model=inference_response.model or text_model, + ) + if inference_response.usage: + usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( + inference_response.usage.prompt_tokens or 0 + ) + usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + ( + getattr(inference_response.usage, "completion_tokens", 0) or 0 + ) + else: + raise RuntimeError("No text embedding client is configured.") # Embed image inputs. if image_items: if not (image_model := opts.get("image_model") or self.image_model): raise ValueError("An image_model is required, either in the client or options, for image inputs.") image_inputs = [img for _, img in image_items] - response = await self._image_client.embed( + image_response = await image_client.embed( input=image_inputs, model=image_model, **common_kwargs, ) - for i, item in enumerate(response.data): + for i, item in enumerate(image_response.data): original_idx = image_items[i][0] image_vector: list[float] = [float(v) for v in item.embedding] embeddings[original_idx] = Embedding( vector=image_vector, dimensions=len(image_vector), - model=response.model or image_model, + model=image_response.model or image_model, ) - if response.usage: + if image_response.usage: usage_details["input_token_count"] = (usage_details.get("input_token_count") or 0) + ( - response.usage.prompt_tokens or 0 + image_response.usage.prompt_tokens or 0 ) usage_details["output_token_count"] = (usage_details.get("output_token_count") or 0) + ( - getattr(response.usage, "completion_tokens", 0) or 0 + getattr(image_response.usage, "completion_tokens", 0) or 0 ) return GeneratedEmbeddings( [embedding for embedding in embeddings if embedding is not None], @@ -328,9 +503,8 @@ class FoundryEmbeddingClient( ): """Foundry embedding client with telemetry support. - Supports both text and image inputs in a single client. Pass plain strings - or ``Content`` instances created with ``Content.from_text()`` or - ``Content.from_data()``. + Supports OpenAI text embedding deployments through a Foundry project and + text or image models through a Foundry Models inference endpoint. Keyword Args: model: The text embedding model (e.g. "text-embedding-3-small"). @@ -338,13 +512,20 @@ class FoundryEmbeddingClient( image_model: The image embedding model (e.g. "Cohere-embed-v3-english"). Can also be set via environment variable FOUNDRY_IMAGE_EMBEDDING_MODEL. Falls back to ``model``. + project_endpoint: The Foundry project endpoint URL used for OpenAI + embedding deployments. Can also be set via environment variable + FOUNDRY_PROJECT_ENDPOINT. + project_client: An existing ``AIProjectClient``. endpoint: The Foundry inference endpoint URL. Can also be set via environment variable FOUNDRY_MODELS_ENDPOINT. api_key: API key for authentication. Can also be set via environment variable FOUNDRY_MODELS_API_KEY. text_client: Optional pre-configured ``EmbeddingsClient``. image_client: Optional pre-configured ``ImageEmbeddingsClient``. - credential: Optional ``AzureKeyCredential`` or token credential. + credential: Async Azure credential. + allow_preview: Enables preview opt-in on an internally created + ``AIProjectClient``. + default_headers: Additional HTTP headers for project OpenAI requests. otel_provider_name: Override for the OpenTelemetry provider name. env_file_path: Path to .env file for settings. env_file_encoding: Encoding for .env file. @@ -354,12 +535,16 @@ class FoundryEmbeddingClient( from agent_framework_foundry import FoundryEmbeddingClient - # Using environment variables + # OpenAI embedding deployment in a Foundry project + # Set FOUNDRY_PROJECT_ENDPOINT=https://your-resource.services.ai.azure.com/api/projects/your-project + # Set FOUNDRY_EMBEDDING_MODEL=text-embedding-3-small + client = FoundryEmbeddingClient(credential=azure_credential) + + # Foundry Models inference endpoint (required for image embeddings) # Set FOUNDRY_MODELS_ENDPOINT=https://your-endpoint.inference.ai.azure.com # Set FOUNDRY_MODELS_API_KEY=your-key - # Set FOUNDRY_EMBEDDING_MODEL=text-embedding-3-small # Set FOUNDRY_IMAGE_EMBEDDING_MODEL=Cohere-embed-v3-english - client = FoundryEmbeddingClient() + image_client = FoundryEmbeddingClient() # Text embeddings result = await client.get_embeddings(["Hello, world!"]) @@ -368,10 +553,10 @@ class FoundryEmbeddingClient( from agent_framework import Content image = Content.from_data(data=image_bytes, media_type="image/png") - result = await client.get_embeddings([image]) + result = await image_client.get_embeddings([image]) # Mixed text and image - result = await client.get_embeddings(["hello", image]) + result = await image_client.get_embeddings(["hello", image]) """ OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.inference" @@ -382,10 +567,14 @@ def __init__( model: str | None = None, image_model: str | None = None, endpoint: str | None = None, + project_endpoint: str | None = None, + project_client: AIProjectClient | None = None, api_key: str | SecretString | None = None, text_client: EmbeddingsClient | None = None, image_client: ImageEmbeddingsClient | None = None, - credential: AzureKeyCredential | None = None, + credential: AzureKeyCredential | AsyncTokenCredential | None = None, + allow_preview: bool | None = None, + default_headers: Mapping[str, str] | None = None, otel_provider_name: str | None = None, additional_properties: dict[str, Any] | None = None, env_file_path: str | None = None, @@ -396,12 +585,18 @@ def __init__( model=model, image_model=image_model, endpoint=endpoint, + project_endpoint=project_endpoint, + project_client=project_client, api_key=api_key, text_client=text_client, image_client=image_client, credential=credential, + allow_preview=allow_preview, + default_headers=default_headers, additional_properties=additional_properties, otel_provider_name=otel_provider_name, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) + if otel_provider_name is None and self.project_client is not None: + self.otel_provider_name = "azure.ai.foundry" diff --git a/python/packages/foundry/agent_framework_foundry/_feature_usage.py b/python/packages/foundry/agent_framework_foundry/_feature_usage.py index e8a13ed52b6..79fb7fcaf4e 100644 --- a/python/packages/foundry/agent_framework_foundry/_feature_usage.py +++ b/python/packages/foundry/agent_framework_foundry/_feature_usage.py @@ -32,6 +32,7 @@ class FeatureIndex(IntEnum): _FOUNDRY_ORIGIN_SUFFIXES = ( "inference.ai.azure.com", + "openai.azure.com", "services.ai.azure.com", ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 7ccc8430797..bf9172985d2 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -53,12 +53,19 @@ class OutputStruct(BaseModel): weather: str | None = None -def test_foundry_feature_usage_policy_refreshes_user_agent() -> None: +@pytest.mark.parametrize( + "url", + [ + "https://project.services.ai.azure.com/api/projects/test", + "https://project.openai.azure.com/openai/v1/embeddings", + ], +) +def test_foundry_feature_usage_policy_refreshes_user_agent(url: str) -> None: with telemetry._feature_mask_lock: telemetry._feature_mask = 0 mark_feature_used(FeatureIndex.FOUNDRY_CHAT_CLIENT) request = MagicMock() - request.http_request.url = "https://project.services.ai.azure.com/api/projects/test" + request.http_request.url = url request.http_request.headers = {"User-Agent": "azsdk-python-ai-projects/1.0 agent-framework-python/1.0"} FeatureUsagePolicy().on_request(request) diff --git a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py index 2eec8ea0672..e56507488bf 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_embedding_client.py @@ -4,13 +4,14 @@ import os from collections.abc import Sequence -from typing import Any +from typing import Any, cast from unittest.mock import ANY, AsyncMock, MagicMock, patch import pytest from agent_framework import Content, SecretString -from agent_framework._telemetry import get_user_agent +from agent_framework._telemetry import USER_AGENT_KEY, get_user_agent from azure.core.credentials import AzureKeyCredential +from azure.identity.aio import AzureCliCredential from agent_framework_foundry import ( FoundryEmbeddingClient, @@ -23,17 +24,20 @@ def _make_embed_response( embeddings: Sequence[list[float]], model: str = "test-model", prompt_tokens: int = 10, + indices: Sequence[int] | None = None, ) -> MagicMock: """Create a mock EmbeddingsResult.""" data = [] - for emb in embeddings: + for position, emb in enumerate(embeddings): item = MagicMock() item.embedding = emb + item.index = indices[position] if indices is not None else position data.append(item) usage = MagicMock() usage.prompt_tokens = prompt_tokens usage.completion_tokens = 0 + usage.total_tokens = prompt_tokens result = MagicMock() result.data = data @@ -42,6 +46,19 @@ def _make_embed_response( return result +def _make_openai_client( + embeddings: Sequence[list[float]] = ([0.1, 0.2, 0.3],), + *, + indices: Sequence[int] | None = None, +) -> MagicMock: + """Create a mock OpenAI client exposed by AIProjectClient.""" + client = MagicMock() + client.base_url = "https://test.services.ai.azure.com/api/projects/test/openai/v1/" + client.embeddings.create = AsyncMock(return_value=_make_embed_response(embeddings, indices=indices)) + client.close = AsyncMock() + return client + + @pytest.fixture def mock_text_client() -> AsyncMock: """Create a mock text EmbeddingsClient.""" @@ -190,6 +207,210 @@ def test_service_url(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None: """service_url returns the configured endpoint.""" assert raw_client.service_url() == "https://test.inference.ai.azure.com" + async def test_project_client_text_embeddings(self) -> None: + """OpenAI deployments are called through an existing project client.""" + openai_client = _make_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + project_client.close = AsyncMock() + client = RawFoundryEmbeddingClient( + model="text-embedding-3-small", + project_client=project_client, + ) + + result = await client.get_embeddings(["hello"]) + + project_client.get_openai_client.assert_called_once_with() + openai_client.embeddings.create.assert_awaited_once_with( + input=["hello"], + model="text-embedding-3-small", + ) + assert result[0].vector == [0.1, 0.2, 0.3] + assert result[0].dimensions == 3 + assert result[0].model == "test-model" + assert result.usage == {"input_token_count": 10, "total_token_count": 10} + assert client.service_url() == "https://test.openai.azure.com/openai/v1/" + assert str(openai_client.base_url) == "https://test.openai.azure.com/openai/v1/" + + await client.close() + openai_client.close.assert_awaited_once() + project_client.close.assert_not_called() + + async def test_project_client_options_and_response_order(self) -> None: + """Project requests pass options through and restore response ordering.""" + openai_client = _make_openai_client([[0.3], [0.1]], indices=[1, 0]) + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + client = RawFoundryEmbeddingClient( + model="text-embedding-3-small", + project_client=project_client, + ) + + result = await client.get_embeddings( + ["first", "second"], + options={ + "model": "text-embedding-3-large", + "dimensions": 256, + "encoding_format": "float", + "input_type": "document", + "extra_parameters": {"custom": "value"}, + }, + ) + + openai_client.embeddings.create.assert_awaited_once_with( + input=["first", "second"], + model="text-embedding-3-large", + dimensions=256, + encoding_format="float", + extra_body={"custom": "value", "input_type": "document"}, + ) + assert [embedding.vector for embedding in result] == [[0.1], [0.3]] + + async def test_project_mode_rejects_images_before_sending_text(self) -> None: + """Project OpenAI embedding deployments reject image inputs without partial requests.""" + openai_client = _make_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + client = RawFoundryEmbeddingClient( + model="text-embedding-3-small", + project_client=project_client, + ) + image = Content.from_data(data=b"\x89PNG", media_type="image/png") + + with pytest.raises(ValueError, match="Image embeddings require a Foundry Models inference endpoint"): + await client.get_embeddings(["hello", image]) + + openai_client.embeddings.create.assert_not_awaited() + + async def test_owned_project_client_is_closed(self) -> None: + """A project client created by the embedding client is closed with it.""" + openai_client = _make_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + project_client.close = AsyncMock() + + with patch("azure.ai.projects.aio.AIProjectClient", return_value=project_client): + client = RawFoundryEmbeddingClient( + model="text-embedding-3-small", + project_endpoint="https://test.services.ai.azure.com/api/projects/test", + credential=MagicMock(), + ) + + await client.close() + + openai_client.close.assert_awaited_once() + project_client.close.assert_awaited_once() + + def test_project_endpoint_from_env_ignores_empty_models_endpoint(self) -> None: + """Empty Models settings do not override a configured project endpoint.""" + openai_client = _make_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + credential = MagicMock() + default_headers = {"X-Test": "value"} + + with ( + patch.dict( + os.environ, + { + "FOUNDRY_PROJECT_ENDPOINT": "https://test.services.ai.azure.com/api/projects/test", + "FOUNDRY_MODELS_ENDPOINT": "", + "FOUNDRY_MODELS_API_KEY": "", + "FOUNDRY_EMBEDDING_MODEL": "text-embedding-3-small", + }, + clear=True, + ), + patch( + "azure.ai.projects.aio.AIProjectClient", + return_value=project_client, + ) as project_client_type, + ): + client = RawFoundryEmbeddingClient( + credential=credential, + allow_preview=True, + default_headers=default_headers, + ) + + assert client.project_client is project_client + assert project_client_type.call_args.kwargs["endpoint"] == ( + "https://test.services.ai.azure.com/api/projects/test" + ) + assert project_client_type.call_args.kwargs["credential"] is credential + assert project_client_type.call_args.kwargs["allow_preview"] is True + assert project_client_type.call_args.kwargs["user_agent"] == get_user_agent() + project_client.get_openai_client.assert_called_once_with( + default_headers=default_headers, + http_client=ANY, + ) + + def test_project_endpoint_requires_credential(self) -> None: + """Creating a project client requires a token credential.""" + with patch.dict( + os.environ, + { + "FOUNDRY_PROJECT_ENDPOINT": "https://test.services.ai.azure.com/api/projects/test", + "FOUNDRY_EMBEDDING_MODEL": "text-embedding-3-small", + }, + clear=True, + ): + with pytest.raises(ValueError, match="Azure credential is required"): + RawFoundryEmbeddingClient() + + with pytest.raises(ValueError, match="A token credential is required"): + RawFoundryEmbeddingClient(credential=AzureKeyCredential("test-key")) + + def test_explicit_project_and_inference_sources_raise(self) -> None: + """Explicit project and Models endpoint configuration cannot be combined.""" + with pytest.raises(ValueError, match="cannot be combined with Foundry Models"): + RawFoundryEmbeddingClient( + model="text-embedding-3-small", + project_client=MagicMock(), + endpoint="https://test.inference.ai.azure.com", + ) + + @pytest.mark.parametrize(("endpoint", "api_key"), [("", ""), (" ", " ")]) + def test_blank_explicit_models_values_do_not_conflict_with_project_client( + self, + endpoint: str, + api_key: str, + ) -> None: + """Blank explicit Models settings are absent when selecting project mode.""" + openai_client = _make_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + + client = RawFoundryEmbeddingClient( + model="text-embedding-3-small", + project_client=project_client, + endpoint=endpoint, + api_key=api_key, + ) + + assert client.project_client is project_client + project_client.get_openai_client.assert_called_once_with() + + def test_legacy_models_endpoint_wins_when_both_env_endpoints_are_set(self) -> None: + """Existing inference configuration remains preferred when both endpoints come from env.""" + with ( + patch.dict( + os.environ, + { + "FOUNDRY_PROJECT_ENDPOINT": "https://test.services.ai.azure.com/api/projects/test", + "FOUNDRY_MODELS_ENDPOINT": "https://test.inference.ai.azure.com", + "FOUNDRY_MODELS_API_KEY": "test-key", + "FOUNDRY_EMBEDDING_MODEL": "text-embedding-3-small", + }, + clear=True, + ), + patch("azure.ai.projects.aio.AIProjectClient") as project_client_type, + patch("agent_framework_foundry._embedding_client.EmbeddingsClient") as text_client_type, + patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"), + ): + RawFoundryEmbeddingClient() + + project_client_type.assert_not_called() + text_client_type.assert_called_once() + def test_settings_from_env(self) -> None: """Settings are loaded from environment variables.""" with ( @@ -314,6 +535,57 @@ async def test_otel_provider_name_override(self, mock_text_client: AsyncMock, mo ) assert client.otel_provider_name == "custom-provider" + def test_project_otel_provider_name(self) -> None: + """Project-backed embeddings use the Foundry telemetry provider name.""" + openai_client = _make_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + + client = FoundryEmbeddingClient( + model="text-embedding-3-small", + project_client=project_client, + ) + + assert client.otel_provider_name == "azure.ai.foundry" + + def test_project_client_serialization_round_trip(self) -> None: + """Project-backed clients serialize without leaking an unsupported telemetry field.""" + openai_client = _make_openai_client() + project_client = MagicMock() + project_client.get_openai_client.return_value = openai_client + default_headers = { + "X-Test": "value", + USER_AGENT_KEY: "custom-user-agent", + } + client = FoundryEmbeddingClient( + model="text-embedding-3-small", + project_client=project_client, + default_headers=default_headers, + ) + + serialized = client.to_dict() + + assert "OTEL_PROVIDER_NAME" not in serialized + assert "project_client" not in serialized + assert serialized["default_headers"] == {"X-Test": "value"} + assert serialized["otel_provider_name"] == "azure.ai.foundry" + + restored_openai_client = _make_openai_client() + restored_project_client = MagicMock() + restored_project_client.get_openai_client.return_value = restored_openai_client + restored = FoundryEmbeddingClient.from_dict( + serialized, + dependencies={ + "foundry_embedding_client": { + "project_client": restored_project_client, + } + }, + ) + + assert restored.project_client is restored_project_client + assert restored.otel_provider_name == "azure.ai.foundry" + restored_project_client.get_openai_client.assert_called_once_with(default_headers={"X-Test": "value"}) + _SKIP_REASON = "Foundry inference integration tests disabled" @@ -346,3 +618,31 @@ async def test_text_embedding_live(self) -> None: assert len(result) == 1 assert len(result[0].vector) > 0 assert result[0].model is not None + + +skip_if_foundry_project_embedding_integration_tests_disabled = pytest.mark.skipif( + not os.environ.get("FOUNDRY_PROJECT_ENDPOINT") or not os.environ.get("FOUNDRY_EMBEDDING_MODEL"), + reason="No FOUNDRY_PROJECT_ENDPOINT or FOUNDRY_EMBEDDING_MODEL provided; skipping integration test.", +) + + +class TestFoundryProjectEmbeddingIntegration: + """Integration tests for OpenAI embedding deployments in a Foundry project.""" + + @pytest.mark.flaky + @pytest.mark.integration + @skip_if_foundry_project_embedding_integration_tests_disabled + async def test_text_embedding_live(self) -> None: + """Generate text embeddings through a Foundry project endpoint.""" + async with ( + AzureCliCredential() as credential, + FoundryEmbeddingClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + credential=cast(Any, credential), + ) as client, + ): + result = await client.get_embeddings(["Hello, world!"]) + + assert len(result) == 1 + assert len(result[0].vector) > 0 + assert result[0].model is not None diff --git a/python/samples/02-agents/embeddings/foundry_embeddings.py b/python/samples/02-agents/embeddings/foundry_embeddings.py index b52443249c1..8a08b637fbc 100644 --- a/python/samples/02-agents/embeddings/foundry_embeddings.py +++ b/python/samples/02-agents/embeddings/foundry_embeddings.py @@ -2,6 +2,7 @@ # requires-python = ">=3.10" # dependencies = [ # "agent-framework-foundry", +# "azure-identity", # ] # /// # Run with: uv run samples/02-agents/embeddings/foundry_embeddings.py @@ -9,68 +10,57 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio -import pathlib +import os -from agent_framework import Content from agent_framework.foundry import FoundryEmbeddingClient +from azure.identity.aio import AzureCliCredential from dotenv import load_dotenv load_dotenv() -"""Microsoft Foundry Image Embedding Example +"""Microsoft Foundry OpenAI Embedding Example -This sample demonstrates how to generate image embeddings using the -Foundry embedding client with the Cohere-embed-v3-english model. -Images are passed as ``Content`` objects created with ``Content.from_data()``. +This sample demonstrates how to generate text embeddings with an OpenAI model +deployment exposed through a Microsoft Foundry project. Prerequisites: - Deploy an embedding model to a Foundry-hosted inference endpoint that supports image inputs, - such as Cohere-embed-v3-english. - - The details page for that model, has a target URI and a Key, which should be set in environment variables or a .env - file as follows, the target URI should append the `/models` path: - - FOUNDRY_MODELS_ENDPOINT: Your Foundry models endpoint URL, for instance: - https://.azure-api.net//models - - FOUNDRY_MODELS_API_KEY: Your API key - - FOUNDRY_EMBEDDING_MODEL: The text embedding model name - (e.g. "text-embedding-3-small") - - FOUNDRY_IMAGE_EMBEDDING_MODEL: The image embedding model name - (e.g. "Cohere-embed-v3-english") + Sign in with ``az login`` and set: + - FOUNDRY_PROJECT_ENDPOINT: Your Foundry project endpoint, for example: + https://.services.ai.azure.com/api/projects/ + - FOUNDRY_EMBEDDING_MODEL: Your embedding deployment name, for example: + text-embedding-3-small """ -SAMPLE_IMAGE_PATH = pathlib.Path(__file__).parent.parent.parent / "shared" / "sample_assets" / "sample_image.jpg" - async def main() -> None: - """Generate image embeddings with Foundry.""" - async with FoundryEmbeddingClient() as client: - # 1. Generate an image embedding. - image_bytes = SAMPLE_IMAGE_PATH.read_bytes() - image_content = Content.from_data(data=image_bytes, media_type="image/jpeg") - result = await client.get_embeddings([image_content]) - print(f"Image embedding dimensions: {result[0].dimensions}") + """Generate text embeddings through a Foundry project.""" + async with AzureCliCredential() as credential, FoundryEmbeddingClient( + project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"], + model=os.environ["FOUNDRY_EMBEDDING_MODEL"], + credential=credential, + ) as client: + # 1. Generate a single embedding. + result = await client.get_embeddings(["Hello, world!"]) + print(f"Single embedding dimensions: {result[0].dimensions}") print(f"First 5 values: {result[0].vector[:5]}") print(f"Model: {result[0].model}") print(f"Usage: {result.usage}") print() - # 2. Generate image and text embeddings separately in one call. - # The client dispatches text to the text endpoint and images to the image - # endpoint, then reassembles results in the original input order. - result = await client.get_embeddings(["A half-timbered house in a forested valley", image_content]) - print(f"Text embedding dimensions: {result[0].dimensions}") - print(f"First 5 values: {result[0].vector[:5]}") - print(f"Image embedding dimensions: {result[1].dimensions}") - print(f"First 5 values: {result[1].vector[:5]}") + # 2. Generate embeddings for multiple inputs. + texts = [ + "The weather is sunny today.", + "It is raining outside.", + "Machine learning is fascinating.", + ] + result = await client.get_embeddings(texts) + print(f"Batch of {len(result)} embeddings, each with {result[0].dimensions} dimensions") + print(f"First embedding vector: {result[0].vector[:5]}") print() - # 3. Generate image embeddings with input_type option. - result = await client.get_embeddings( - [image_content], - options={"input_type": "document"}, - ) - print(f"Document embedding dimensions: {result[0].dimensions}") - print(f"First 5 values: {result[0].vector[:5]}") + # 3. Generate an embedding with custom dimensions. + result = await client.get_embeddings(["Custom dimensions example"], options={"dimensions": 256}) + print(f"Custom dimensions: {result[0].dimensions}") if __name__ == "__main__": @@ -78,17 +68,14 @@ async def main() -> None: """ -Sample output (using deployment: Cohere-embed-v3-english, which is Cohere's "embed-english-v3.0-image" model): -Image embedding dimensions: 1024 -First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] -Model: embed-english-v3.0-image -Usage: {'input_token_count': 1000, 'output_token_count': 0} - -Text embedding dimensions: 1536 -First 5 values: [-0.019439403, 0.015791258, 0.012358093, 0.0028533707, -0.01649483] -Image embedding dimensions: 1024 -First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] - -Document embedding dimensions: 1024 -First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] +Sample output: +Single embedding dimensions: 1536 +First 5 values: [0.012, -0.034, 0.056, -0.078, 0.09] +Model: text-embedding-3-small +Usage: {'input_token_count': 4, 'total_token_count': 4} + +Batch of 3 embeddings, each with 1536 dimensions +First embedding vector: [0.012, -0.034, 0.056, -0.078, 0.09] + +Custom dimensions: 256 """ diff --git a/python/samples/02-agents/embeddings/foundry_image_embeddings.py b/python/samples/02-agents/embeddings/foundry_image_embeddings.py new file mode 100644 index 00000000000..a0d4489de2d --- /dev/null +++ b/python/samples/02-agents/embeddings/foundry_image_embeddings.py @@ -0,0 +1,94 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "agent-framework-foundry", +# ] +# /// +# Run with: uv run samples/02-agents/embeddings/foundry_image_embeddings.py + +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import pathlib + +from agent_framework import Content +from agent_framework.foundry import FoundryEmbeddingClient +from dotenv import load_dotenv + +load_dotenv() + +"""Microsoft Foundry Image Embedding Example + +This sample demonstrates how to generate image embeddings using the +Foundry embedding client with the Cohere-embed-v3-english model. +Images are passed as ``Content`` objects created with ``Content.from_data()``. + +Prerequisites: + Deploy an embedding model to a Foundry-hosted inference endpoint that supports image inputs, + such as Cohere-embed-v3-english. + + The model details page provides a target URI and key. Set them using the + following environment variables or a .env file, and append `/models` to the target URI: + - FOUNDRY_MODELS_ENDPOINT: Your Foundry models endpoint URL, for instance: + https://.azure-api.net//models + - FOUNDRY_MODELS_API_KEY: Your API key + - FOUNDRY_EMBEDDING_MODEL: The text embedding model name + (e.g. "text-embedding-3-small") + - FOUNDRY_IMAGE_EMBEDDING_MODEL: The image embedding model name + (e.g. "Cohere-embed-v3-english") +""" + +SAMPLE_IMAGE_PATH = pathlib.Path(__file__).parent.parent.parent / "shared" / "sample_assets" / "sample_image.jpg" + + +async def main() -> None: + """Generate image embeddings with Foundry.""" + async with FoundryEmbeddingClient() as client: + # 1. Generate an image embedding. + image_bytes = SAMPLE_IMAGE_PATH.read_bytes() + image_content = Content.from_data(data=image_bytes, media_type="image/jpeg") + result = await client.get_embeddings([image_content]) + print(f"Image embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + print(f"Model: {result[0].model}") + print(f"Usage: {result.usage}") + print() + + # 2. Generate image and text embeddings separately in one call. + # The client dispatches text to the text endpoint and images to the image + # endpoint, then reassembles results in the original input order. + result = await client.get_embeddings(["A half-timbered house in a forested valley", image_content]) + print(f"Text embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + print(f"Image embedding dimensions: {result[1].dimensions}") + print(f"First 5 values: {result[1].vector[:5]}") + print() + + # 3. Generate image embeddings with input_type option. + result = await client.get_embeddings( + [image_content], + options={"input_type": "document"}, + ) + print(f"Document embedding dimensions: {result[0].dimensions}") + print(f"First 5 values: {result[0].vector[:5]}") + + +if __name__ == "__main__": + asyncio.run(main()) + + +""" +Sample output (using deployment: Cohere-embed-v3-english, which is Cohere's "embed-english-v3.0-image" model): +Image embedding dimensions: 1024 +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] +Model: embed-english-v3.0-image +Usage: {'input_token_count': 1000, 'output_token_count': 0} + +Text embedding dimensions: 1536 +First 5 values: [-0.019439403, 0.015791258, 0.012358093, 0.0028533707, -0.01649483] +Image embedding dimensions: 1024 +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] + +Document embedding dimensions: 1024 +First 5 values: [0.029159546, -0.007926941, -0.0032978058, -0.0030403137, -0.012786865] +""" diff --git a/python/samples/README.md b/python/samples/README.md index 84e36b2573d..34eb44c2931 100644 --- a/python/samples/README.md +++ b/python/samples/README.md @@ -100,6 +100,7 @@ variable. | `agent-framework-anthropic` | `AnthropicClient` | `ANTHROPIC_CHAT_MODEL` | `claude-sonnet-4-5-20250929` | | `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_MODELS_ENDPOINT` | `https://my-endpoint.inference.ai.azure.com` | | `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_MODELS_API_KEY` | `env-key` | +| `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_PROJECT_ENDPOINT` | `https://my-project.services.ai.azure.com/api/projects/my-project` | | `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_EMBEDDING_MODEL` | `text-embedding-3-small` | | `agent-framework-foundry` | `FoundryEmbeddingClient` | `FOUNDRY_IMAGE_EMBEDDING_MODEL` | `Cohere-embed-v3-english` | | `agent-framework-azure-ai-search` | `AzureAISearchContextProvider` | `AZURE_SEARCH_ENDPOINT` | `https://my-search.search.windows.net` |