Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -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://<your-account>.services.ai.azure.com
# https://<your-account>.openai.azure.com
# A project-scoped Foundry URL (".../api/projects/<name>") is also accepted and
# automatically normalized to the inference base.
AI_FOUNDRY_ENDPOINT=https://<your-account>.openai.azure.com/
AI_FOUNDRY_API_KEY=
AI_FOUNDRY_EMBEDDING_DEPLOYMENT_NAME=text-embedding-3-large
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<resource>.services.ai.azure.com/api/projects/<name>`) 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)

Expand Down
3 changes: 2 additions & 1 deletion azure/cosmos/agent_memory/_base/base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions azure/cosmos/agent_memory/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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://<resource>.services.ai.azure.com
https://<resource>.openai.azure.com

The Azure AI Foundry portal, however, commonly surfaces a *project*-scoped
endpoint of the form::

https://<resource>.services.ai.azure.com/api/projects/<project-name>

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/<name>`` 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")
Expand Down
10 changes: 10 additions & 0 deletions tests/unit/_base/test_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
72 changes: 72 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
)
Loading