diff --git a/.env.template b/.env.template index fe429f2..a5e0b25 100644 --- a/.env.template +++ b/.env.template @@ -49,6 +49,11 @@ USER_SUMMARY_EVERY_N=20 MEMORY_PROCESSOR_OWNER= # ---- AI Foundry / Azure OpenAI ---- +# Use the account-level inference endpoint. Both of these forms work: +# https://.services.ai.azure.com +# https://.openai.azure.com +# A project-scoped Foundry URL (".../api/projects/") is also accepted and +# automatically normalized to the inference base. AI_FOUNDRY_ENDPOINT=https://.openai.azure.com/ AI_FOUNDRY_API_KEY= AI_FOUNDRY_EMBEDDING_DEPLOYMENT_NAME=text-embedding-3-large diff --git a/CHANGELOG.md b/CHANGELOG.md index c785bdb..3cc95cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and `flat`. This lets the toolkit run against Cosmos DB accounts without the DiskANN capability (for example the classic Cosmos DB emulator), enabling emulator-backed integration test pipelines. +* `ai_foundry_endpoint` now accepts a project-scoped Azure AI Foundry URL + (`https://.services.ai.azure.com/api/projects/`) in addition + to the account-level inference endpoint. The project path is automatically + stripped to the inference base, so callers can paste whichever form the + Foundry portal shows them without hitting opaque 404s. ### 0.1.0b2 (2026-06-03) diff --git a/azure/cosmos/agent_memory/_base/base_client.py b/azure/cosmos/agent_memory/_base/base_client.py index 2fef0a5..aa1dd6c 100644 --- a/azure/cosmos/agent_memory/_base/base_client.py +++ b/azure/cosmos/agent_memory/_base/base_client.py @@ -14,6 +14,7 @@ _resolve_cosmos_provisioning_autoscale_max_ru, _resolve_cosmos_throughput_mode, _resolve_embedding_dimensions, + normalize_ai_foundry_endpoint, ) from azure.cosmos.agent_memory.exceptions import CosmosNotConnectedError, MemoryNotFoundError, ValidationError from azure.cosmos.agent_memory.logging import configure_logging, get_logger @@ -69,7 +70,7 @@ def _init_base_config( autoscale_max_ru=cosmos_autoscale_max_ru, ) - self._ai_foundry_endpoint = ai_foundry_endpoint + self._ai_foundry_endpoint = normalize_ai_foundry_endpoint(ai_foundry_endpoint) self._ai_foundry_credential = ai_foundry_credential self._ai_foundry_api_key = ai_foundry_api_key self._embedding_deployment_name = embedding_deployment_name diff --git a/azure/cosmos/agent_memory/_utils.py b/azure/cosmos/agent_memory/_utils.py index bbf0928..57fe7a6 100644 --- a/azure/cosmos/agent_memory/_utils.py +++ b/azure/cosmos/agent_memory/_utils.py @@ -12,6 +12,7 @@ import uuid from datetime import datetime, timezone from typing import Any, Optional +from urllib.parse import urlsplit, urlunsplit from ._container_routing import USER_SCOPED_MEMORIES_TYPES from ._query_builder import _QueryBuilder @@ -173,6 +174,49 @@ def _resolve_embedding_dimensions(val: Optional[int]) -> int: return parsed +_AI_FOUNDRY_PROJECT_PATH_RE = re.compile(r"/api/projects/[^/]+/?.*$", re.IGNORECASE) +_AI_FOUNDRY_HOST_SUFFIX = ".services.ai.azure.com" + + +def normalize_ai_foundry_endpoint(endpoint: Optional[str]) -> Optional[str]: + """Normalize an AI Foundry / Azure OpenAI endpoint to the inference base URL. + + The toolkit reaches the model inference API through the OpenAI SDK + (``AzureOpenAI(azure_endpoint=...)``), which expects the account-level + inference endpoint, for example:: + + https://.services.ai.azure.com + https://.openai.azure.com + + The Azure AI Foundry portal, however, commonly surfaces a *project*-scoped + endpoint of the form:: + + https://.services.ai.azure.com/api/projects/ + + For ``*.services.ai.azure.com`` resources the project path lives on the same + host that serves inference, so this helper strips a trailing + ``/api/projects/`` segment (plus any surrounding whitespace or trailing + slash) to recover the base inference endpoint. Callers can therefore paste + either form. + + The project-path stripping is applied **only** when the URL host ends with + ``.services.ai.azure.com``, and only to the path component, so unrelated + endpoints that happen to contain ``/api/projects/...`` in their path are left + untouched. Endpoints that don't carry a project path are returned unchanged + aside from whitespace/trailing-slash trimming, so non-Foundry endpoints keep + working. ``None``/empty values are passed through untouched. + """ + if not endpoint: + return endpoint + trimmed = endpoint.strip() + parts = urlsplit(trimmed) + host = parts.hostname or "" + if host.lower().endswith(_AI_FOUNDRY_HOST_SUFFIX): + new_path = _AI_FOUNDRY_PROJECT_PATH_RE.sub("", parts.path) + trimmed = urlunsplit((parts.scheme, parts.netloc, new_path, parts.query, parts.fragment)) + return trimmed.rstrip("/") + + _ALLOWED_EMBEDDING_DATA_TYPES = ("float32", "uint8", "int8") _ALLOWED_DISTANCE_FUNCTIONS = ("cosine", "dotproduct", "euclidean") _ALLOWED_VECTOR_INDEX_TYPES = ("diskANN", "quantizedFlat", "flat") diff --git a/tests/unit/_base/test_base_client.py b/tests/unit/_base/test_base_client.py index ea44401..0d4480d 100644 --- a/tests/unit/_base/test_base_client.py +++ b/tests/unit/_base/test_base_client.py @@ -66,6 +66,16 @@ def test_base_config_validates_throughput_mode(): DummyClient(cosmos_throughput_mode="invalid") +def test_base_config_normalizes_ai_foundry_project_endpoint(): + client = DummyClient(ai_foundry_endpoint="https://my-res.services.ai.azure.com/api/projects/my-project") + assert client._ai_foundry_endpoint == "https://my-res.services.ai.azure.com" + + +def test_base_config_leaves_plain_ai_foundry_endpoint_untouched(): + client = DummyClient(ai_foundry_endpoint="https://my-res.services.ai.azure.com") + assert client._ai_foundry_endpoint == "https://my-res.services.ai.azure.com" + + def test_require_cosmos_guard_and_context_manager(): client = DummyClient() diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 91097d7..e8ef94c 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -12,6 +12,7 @@ _resolve_full_text_language, _resolve_vector_index_type, compute_content_hash, + normalize_ai_foundry_endpoint, ) from azure.cosmos.agent_memory.exceptions import ConfigurationError, ValidationError @@ -278,3 +279,74 @@ def test_resolve_full_text_language_defaults(monkeypatch): def test_resolve_full_text_language_from_env(monkeypatch): monkeypatch.setenv("COSMOS_DB_FULL_TEXT_LANGUAGE", "fr-FR") assert _resolve_full_text_language(None) == "fr-FR" + + +# --------------------------------------------------------------------------- +# normalize_ai_foundry_endpoint +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("value", [None, ""]) +def test_normalize_ai_foundry_endpoint_passes_through_empty(value): + assert normalize_ai_foundry_endpoint(value) == value + + +def test_normalize_ai_foundry_endpoint_strips_project_path(): + assert ( + normalize_ai_foundry_endpoint("https://my-res.services.ai.azure.com/api/projects/my-project") + == "https://my-res.services.ai.azure.com" + ) + + +def test_normalize_ai_foundry_endpoint_strips_project_path_with_trailing_slash(): + assert ( + normalize_ai_foundry_endpoint("https://my-res.services.ai.azure.com/api/projects/my-project/") + == "https://my-res.services.ai.azure.com" + ) + + +def test_normalize_ai_foundry_endpoint_strips_project_path_with_extra_segments(): + assert ( + normalize_ai_foundry_endpoint("https://my-res.services.ai.azure.com/api/projects/my-project/deployments/x") + == "https://my-res.services.ai.azure.com" + ) + + +def test_normalize_ai_foundry_endpoint_leaves_base_services_endpoint(): + assert ( + normalize_ai_foundry_endpoint("https://my-res.services.ai.azure.com") == "https://my-res.services.ai.azure.com" + ) + + +def test_normalize_ai_foundry_endpoint_leaves_openai_endpoint(): + assert normalize_ai_foundry_endpoint("https://my-res.openai.azure.com") == "https://my-res.openai.azure.com" + + +def test_normalize_ai_foundry_endpoint_trims_whitespace_and_trailing_slash(): + assert ( + normalize_ai_foundry_endpoint(" https://my-res.services.ai.azure.com/ ") + == "https://my-res.services.ai.azure.com" + ) + + +def test_normalize_ai_foundry_endpoint_is_case_insensitive_on_project_path(): + assert ( + normalize_ai_foundry_endpoint("https://my-res.services.ai.azure.com/API/Projects/my-project") + == "https://my-res.services.ai.azure.com" + ) + + +def test_normalize_ai_foundry_endpoint_leaves_non_foundry_host_with_project_path(): + # A non-Foundry host that happens to carry /api/projects/ in its path must be + # left untouched (aside from trailing-slash trimming). + assert ( + normalize_ai_foundry_endpoint("https://example.com/api/projects/my-project") + == "https://example.com/api/projects/my-project" + ) + + +def test_normalize_ai_foundry_endpoint_leaves_openai_host_with_project_path(): + assert ( + normalize_ai_foundry_endpoint("https://my-res.openai.azure.com/api/projects/my-project") + == "https://my-res.openai.azure.com/api/projects/my-project" + )