From 2613e4ed0cc1a2c5c1fc4c0dfb9dc20070c4c96f Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 24 Jun 2026 14:24:09 +0100 Subject: [PATCH 1/3] Accept project-scoped AI Foundry endpoints Normalize ai_foundry_endpoint so a project-scoped Foundry URL (.../api/projects/) is stripped to the inference base the OpenAI SDK expects. Lets callers paste whichever endpoint form the Foundry portal shows them. Adds unit tests, CHANGELOG, and .env.template guidance. --- .env.template | 5 ++ CHANGELOG.md | 9 +++ .../cosmos/agent_memory/_base/base_client.py | 3 +- azure/cosmos/agent_memory/_utils.py | 35 +++++++++++ tests/unit/_base/test_base_client.py | 12 ++++ tests/unit/test_utils.py | 63 +++++++++++++++++++ 6 files changed, 126 insertions(+), 1 deletion(-) diff --git a/.env.template b/.env.template index e65d691..8225529 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 8d694bc..ef5a453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## Release History +### Unreleased + +#### Other Changes +* `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) #### Bugs Fixed 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 8fc8e8b..96c7ec8 100644 --- a/azure/cosmos/agent_memory/_utils.py +++ b/azure/cosmos/agent_memory/_utils.py @@ -173,6 +173,41 @@ def _resolve_embedding_dimensions(val: Optional[int]) -> int: return parsed +_AI_FOUNDRY_PROJECT_PATH_RE = re.compile(r"/api/projects/[^/]+/?.*$", re.IGNORECASE) + + +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. + + 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() + trimmed = _AI_FOUNDRY_PROJECT_PATH_RE.sub("", trimmed) + return trimmed.rstrip("/") + + _ALLOWED_EMBEDDING_DATA_TYPES = ("float32", "uint8", "int8") _ALLOWED_DISTANCE_FUNCTIONS = ("cosine", "dotproduct", "euclidean") diff --git a/tests/unit/_base/test_base_client.py b/tests/unit/_base/test_base_client.py index ea44401..4ed137b 100644 --- a/tests/unit/_base/test_base_client.py +++ b/tests/unit/_base/test_base_client.py @@ -66,6 +66,18 @@ 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 826179a..3abedb7 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -10,6 +10,7 @@ _resolve_embedding_data_type, _resolve_full_text_language, compute_content_hash, + normalize_ai_foundry_endpoint, ) from azure.cosmos.agent_memory.exceptions import ConfigurationError, ValidationError @@ -234,3 +235,65 @@ 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" + ) + From 731edf6de279610f016d8c81aef6fb416ce7cdaa Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Thu, 25 Jun 2026 15:22:42 +0100 Subject: [PATCH 2/3] Apply ruff format to test files --- tests/unit/_base/test_base_client.py | 4 +--- tests/unit/test_utils.py | 13 +++---------- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/tests/unit/_base/test_base_client.py b/tests/unit/_base/test_base_client.py index 4ed137b..0d4480d 100644 --- a/tests/unit/_base/test_base_client.py +++ b/tests/unit/_base/test_base_client.py @@ -67,9 +67,7 @@ def test_base_config_validates_throughput_mode(): 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" - ) + 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" diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 3abedb7..e2ada1f 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -263,25 +263,19 @@ def test_normalize_ai_foundry_endpoint_strips_project_path_with_trailing_slash() 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" - ) + 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" + 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" - ) + 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(): @@ -296,4 +290,3 @@ def test_normalize_ai_foundry_endpoint_is_case_insensitive_on_project_path(): normalize_ai_foundry_endpoint("https://my-res.services.ai.azure.com/API/Projects/my-project") == "https://my-res.services.ai.azure.com" ) - From 74d25f8d190e0bc46ee229d5562b737105888832 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Thu, 25 Jun 2026 15:26:14 +0100 Subject: [PATCH 3/3] Only strip Foundry project path for services.ai.azure.com hosts Parse the endpoint and apply the /api/projects/ stripping only when the host ends with .services.ai.azure.com, operating on the path component only, so unrelated endpoints that contain /api/projects/ in their path are left untouched. Add regression tests for non-Foundry and openai.azure.com hosts. --- azure/cosmos/agent_memory/_utils.py | 17 +++++++++++++---- tests/unit/test_utils.py | 16 ++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/azure/cosmos/agent_memory/_utils.py b/azure/cosmos/agent_memory/_utils.py index 96c7ec8..19cac0d 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 @@ -174,6 +175,7 @@ def _resolve_embedding_dimensions(val: Optional[int]) -> int: _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]: @@ -197,14 +199,21 @@ def normalize_ai_foundry_endpoint(endpoint: Optional[str]) -> Optional[str]: slash) to recover the base inference endpoint. Callers can therefore paste either form. - 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. + 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() - trimmed = _AI_FOUNDRY_PROJECT_PATH_RE.sub("", trimmed) + 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("/") diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index e2ada1f..e5039d3 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -290,3 +290,19 @@ def test_normalize_ai_foundry_endpoint_is_case_insensitive_on_project_path(): 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" + )