chore: import upstream snapshot with attribution
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from pytest import fixture
|
||||
|
||||
|
||||
@fixture
|
||||
def exclude_list(request: Any) -> list[str]:
|
||||
"""Fixture that returns a list of environment variables to exclude."""
|
||||
return request.param if hasattr(request, "param") else []
|
||||
|
||||
|
||||
@fixture
|
||||
def override_env_param_dict(request: Any) -> dict[str, str]:
|
||||
"""Fixture that returns a dict of environment variables to override."""
|
||||
return request.param if hasattr(request, "param") else {}
|
||||
|
||||
|
||||
@fixture()
|
||||
def foundry_unit_test_env(monkeypatch, exclude_list, override_env_param_dict): # type: ignore
|
||||
"""Fixture to set environment variables for Foundry settings."""
|
||||
|
||||
if exclude_list is None:
|
||||
exclude_list = []
|
||||
|
||||
if override_env_param_dict is None:
|
||||
override_env_param_dict = {}
|
||||
|
||||
env_vars = {
|
||||
"FOUNDRY_PROJECT_ENDPOINT": "https://test-project.services.ai.azure.com/",
|
||||
"FOUNDRY_MODEL": "test-gpt-4o",
|
||||
}
|
||||
|
||||
env_vars.update(override_env_param_dict) # type: ignore
|
||||
|
||||
for key, value in env_vars.items():
|
||||
if key in exclude_list:
|
||||
monkeypatch.delenv(key, raising=False) # type: ignore
|
||||
continue
|
||||
monkeypatch.setenv(key, value) # type: ignore
|
||||
|
||||
return env_vars
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_agents_client() -> MagicMock:
|
||||
"""Fixture that provides a mock AgentsClient."""
|
||||
mock_client = MagicMock()
|
||||
|
||||
# Mock agents property
|
||||
mock_client.create_agent = AsyncMock()
|
||||
mock_client.delete_agent = AsyncMock()
|
||||
|
||||
# Mock agent creation response
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.id = "test-agent-id"
|
||||
mock_client.create_agent.return_value = mock_agent
|
||||
|
||||
# Mock threads property
|
||||
mock_client.threads = MagicMock()
|
||||
mock_client.threads.create = AsyncMock()
|
||||
mock_client.messages.create = AsyncMock()
|
||||
|
||||
# Mock runs property
|
||||
mock_client.runs = MagicMock()
|
||||
mock_client.runs.list = AsyncMock()
|
||||
mock_client.runs.cancel = AsyncMock()
|
||||
mock_client.runs.stream = AsyncMock()
|
||||
mock_client.runs.submit_tool_outputs_stream = AsyncMock()
|
||||
|
||||
return mock_client
|
||||
|
||||
|
||||
@fixture
|
||||
def mock_azure_credential() -> MagicMock:
|
||||
"""Fixture that provides a mock AsyncTokenCredential."""
|
||||
return MagicMock()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import Content
|
||||
|
||||
from agent_framework_foundry import (
|
||||
FoundryEmbeddingClient,
|
||||
FoundryEmbeddingOptions,
|
||||
RawFoundryEmbeddingClient,
|
||||
)
|
||||
|
||||
|
||||
def _make_embed_response(
|
||||
embeddings: Sequence[list[float]],
|
||||
model: str = "test-model",
|
||||
prompt_tokens: int = 10,
|
||||
) -> MagicMock:
|
||||
"""Create a mock EmbeddingsResult."""
|
||||
data = []
|
||||
for emb in embeddings:
|
||||
item = MagicMock()
|
||||
item.embedding = emb
|
||||
data.append(item)
|
||||
|
||||
usage = MagicMock()
|
||||
usage.prompt_tokens = prompt_tokens
|
||||
usage.completion_tokens = 0
|
||||
|
||||
result = MagicMock()
|
||||
result.data = data
|
||||
result.model = model
|
||||
result.usage = usage
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_text_client() -> AsyncMock:
|
||||
"""Create a mock text EmbeddingsClient."""
|
||||
client = AsyncMock()
|
||||
client.embed = AsyncMock(return_value=_make_embed_response([[0.1, 0.2, 0.3]]))
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_image_client() -> AsyncMock:
|
||||
"""Create a mock image ImageEmbeddingsClient."""
|
||||
client = AsyncMock()
|
||||
client.embed = AsyncMock(return_value=_make_embed_response([[0.4, 0.5, 0.6]]))
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def raw_client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> RawFoundryEmbeddingClient[Any]:
|
||||
"""Create a RawFoundryEmbeddingClient with mocked SDK clients."""
|
||||
return RawFoundryEmbeddingClient(
|
||||
model="test-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> FoundryEmbeddingClient[Any]:
|
||||
"""Create a FoundryEmbeddingClient with mocked SDK clients."""
|
||||
return FoundryEmbeddingClient(
|
||||
model="test-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
|
||||
|
||||
class TestRawFoundryEmbeddingClient:
|
||||
"""Tests for the raw Foundry embedding client."""
|
||||
|
||||
async def test_text_embeddings(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Text inputs are dispatched to the text client."""
|
||||
result = await raw_client.get_embeddings(["hello", "world"])
|
||||
assert result is not None
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["input"] == ["hello", "world"]
|
||||
assert call_kwargs.kwargs["model"] == "test-model"
|
||||
|
||||
async def test_text_content_embeddings(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Content.from_text() inputs are dispatched to the text client."""
|
||||
text_content = Content.from_text("hello")
|
||||
await raw_client.get_embeddings([text_content])
|
||||
|
||||
mock_text_client.embed.assert_called_once()
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["input"] == ["hello"]
|
||||
|
||||
async def test_image_content_embeddings(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_image_client: AsyncMock
|
||||
) -> None:
|
||||
"""Image Content inputs are dispatched to the image client."""
|
||||
image_content = Content.from_data(data=b"\x89PNG", media_type="image/png")
|
||||
await raw_client.get_embeddings([image_content])
|
||||
|
||||
mock_image_client.embed.assert_called_once()
|
||||
call_kwargs = mock_image_client.embed.call_args
|
||||
image_inputs = call_kwargs.kwargs["input"]
|
||||
assert len(image_inputs) == 1
|
||||
assert image_inputs[0].image == image_content.uri
|
||||
|
||||
async def test_mixed_text_and_image(
|
||||
self,
|
||||
raw_client: RawFoundryEmbeddingClient[Any],
|
||||
mock_text_client: AsyncMock,
|
||||
mock_image_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Mixed text and image inputs are dispatched to the correct clients."""
|
||||
mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]])
|
||||
mock_image_client.embed.return_value = _make_embed_response([[0.3, 0.4]])
|
||||
|
||||
image = Content.from_data(data=b"\x89PNG", media_type="image/png")
|
||||
await raw_client.get_embeddings(["hello", image, "world"])
|
||||
|
||||
# Text client gets "hello" and "world"
|
||||
text_call = mock_text_client.embed.call_args
|
||||
assert text_call.kwargs["input"] == ["hello", "world"]
|
||||
|
||||
# Image client gets the image
|
||||
image_call = mock_image_client.embed.call_args
|
||||
assert len(image_call.kwargs["input"]) == 1
|
||||
|
||||
async def test_empty_input(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
|
||||
"""Empty input returns empty result."""
|
||||
result = await raw_client.get_embeddings([])
|
||||
assert len(result) == 0
|
||||
|
||||
async def test_options_passed_through(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Options are passed through to the SDK."""
|
||||
options: FoundryEmbeddingOptions = {
|
||||
"dimensions": 512,
|
||||
"input_type": "document",
|
||||
"encoding_format": "float",
|
||||
}
|
||||
await raw_client.get_embeddings(["hello"], options=options)
|
||||
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["dimensions"] == 512
|
||||
assert call_kwargs.kwargs["input_type"] == "document"
|
||||
assert call_kwargs.kwargs["encoding_format"] == "float"
|
||||
|
||||
async def test_model_override_in_options(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""model in options overrides the default."""
|
||||
options: FoundryEmbeddingOptions = {"model": "custom-model"}
|
||||
await raw_client.get_embeddings(["hello"], options=options)
|
||||
|
||||
call_kwargs = mock_text_client.embed.call_args
|
||||
assert call_kwargs.kwargs["model"] == "custom-model"
|
||||
|
||||
async def test_unsupported_content_type_raises(self, raw_client: RawFoundryEmbeddingClient[Any]) -> None:
|
||||
"""Non-text, non-image Content raises ValueError."""
|
||||
error_content = Content("error", message="fail")
|
||||
with pytest.raises(ValueError, match="Unsupported Content type"):
|
||||
await raw_client.get_embeddings([error_content])
|
||||
|
||||
async def test_usage_metadata(
|
||||
self, raw_client: RawFoundryEmbeddingClient[Any], mock_text_client: AsyncMock
|
||||
) -> None:
|
||||
"""Usage metadata is populated from the response."""
|
||||
mock_text_client.embed.return_value = _make_embed_response([[0.1, 0.2]], prompt_tokens=42)
|
||||
result = await raw_client.get_embeddings(["hello"])
|
||||
assert result.usage is not None
|
||||
assert result.usage["input_token_count"] == 42
|
||||
|
||||
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"
|
||||
|
||||
def test_settings_from_env(self) -> None:
|
||||
"""Settings are loaded from environment variables."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com",
|
||||
"FOUNDRY_MODELS_API_KEY": "env-key",
|
||||
"FOUNDRY_EMBEDDING_MODEL": "env-model",
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
|
||||
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
|
||||
):
|
||||
client = RawFoundryEmbeddingClient()
|
||||
assert client.model == "env-model"
|
||||
assert client.image_model == "env-model" # falls back to model
|
||||
|
||||
def test_image_model_from_env(self) -> None:
|
||||
"""image_model is loaded from its own environment variable."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"FOUNDRY_MODELS_ENDPOINT": "https://env.inference.ai.azure.com",
|
||||
"FOUNDRY_MODELS_API_KEY": "env-key",
|
||||
"FOUNDRY_EMBEDDING_MODEL": "text-model",
|
||||
"FOUNDRY_IMAGE_EMBEDDING_MODEL": "image-model",
|
||||
},
|
||||
),
|
||||
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
|
||||
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
|
||||
):
|
||||
client = RawFoundryEmbeddingClient()
|
||||
assert client.model == "text-model"
|
||||
assert client.image_model == "image-model"
|
||||
|
||||
def test_image_model_explicit(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None:
|
||||
"""image_model can be set explicitly."""
|
||||
client = RawFoundryEmbeddingClient(
|
||||
model="text-model",
|
||||
image_model="image-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
assert client.model == "text-model"
|
||||
assert client.image_model == "image-model"
|
||||
|
||||
async def test_image_model_sent_to_image_client(
|
||||
self, mock_text_client: AsyncMock, mock_image_client: AsyncMock
|
||||
) -> None:
|
||||
"""image_model is passed to the image client embed call."""
|
||||
client = RawFoundryEmbeddingClient(
|
||||
model="text-model",
|
||||
image_model="image-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
)
|
||||
image_content = Content.from_data(data=b"\x89PNG", media_type="image/png")
|
||||
await client.get_embeddings([image_content])
|
||||
call_kwargs = mock_image_client.embed.call_args
|
||||
assert call_kwargs.kwargs["model"] == "image-model"
|
||||
|
||||
|
||||
class TestFoundryEmbeddingClient:
|
||||
"""Tests for the telemetry-enabled Foundry embedding client."""
|
||||
|
||||
async def test_text_embeddings(self, client: FoundryEmbeddingClient[Any], mock_text_client: AsyncMock) -> None:
|
||||
"""Text embeddings work through the telemetry layer."""
|
||||
result = await client.get_embeddings(["hello"])
|
||||
assert len(result) == 1
|
||||
assert result[0].vector == [0.1, 0.2, 0.3]
|
||||
|
||||
async def test_otel_provider_name_default(self) -> None:
|
||||
"""Default OTEL provider name is azure.ai.inference."""
|
||||
assert FoundryEmbeddingClient.OTEL_PROVIDER_NAME == "azure.ai.inference"
|
||||
|
||||
async def test_otel_provider_name_override(self, mock_text_client: AsyncMock, mock_image_client: AsyncMock) -> None:
|
||||
"""OTEL provider name can be overridden."""
|
||||
client = FoundryEmbeddingClient(
|
||||
model="test-model",
|
||||
endpoint="https://test.inference.ai.azure.com",
|
||||
api_key="test-key",
|
||||
text_client=mock_text_client,
|
||||
image_client=mock_image_client,
|
||||
otel_provider_name="custom-provider",
|
||||
)
|
||||
assert client.otel_provider_name == "custom-provider"
|
||||
|
||||
|
||||
_SKIP_REASON = "Foundry inference integration tests disabled"
|
||||
|
||||
|
||||
def _foundry_integration_tests_enabled() -> bool:
|
||||
return bool(
|
||||
os.environ.get("FOUNDRY_MODELS_ENDPOINT")
|
||||
and os.environ.get("FOUNDRY_MODELS_API_KEY")
|
||||
and os.environ.get("FOUNDRY_EMBEDDING_MODEL")
|
||||
)
|
||||
|
||||
|
||||
skip_if_foundry_inference_integration_tests_disabled = pytest.mark.skipif(
|
||||
not _foundry_integration_tests_enabled(),
|
||||
reason=_SKIP_REASON,
|
||||
)
|
||||
|
||||
|
||||
class TestFoundryEmbeddingIntegration:
|
||||
"""Integration tests requiring a live Foundry inference endpoint."""
|
||||
|
||||
@pytest.mark.skip(reason="Flaky in merge queue, blocking unrelated PRs. Tracked in #5553.")
|
||||
@pytest.mark.flaky
|
||||
@pytest.mark.integration
|
||||
@skip_if_foundry_inference_integration_tests_disabled
|
||||
async def test_text_embedding_live(self) -> None:
|
||||
"""Generate text embeddings against a live endpoint."""
|
||||
client = FoundryEmbeddingClient()
|
||||
result = await client.get_embeddings(["Hello, world!"])
|
||||
assert len(result) == 1
|
||||
assert len(result[0].vector) > 0
|
||||
assert result[0].model is not None
|
||||
@@ -0,0 +1,503 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, Message
|
||||
from agent_framework._sessions import AgentSession, SessionContext
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
|
||||
from agent_framework_foundry._memory_provider import FoundryMemoryProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_project_client() -> AsyncMock:
|
||||
"""Create a mock AIProjectClient."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.beta = AsyncMock()
|
||||
mock_client.beta.memory_stores = AsyncMock()
|
||||
mock_client.beta.memory_stores.search_memories = AsyncMock()
|
||||
mock_client.beta.memory_stores.begin_update_memories = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credential() -> Mock:
|
||||
"""Create a mock Azure credential."""
|
||||
return Mock()
|
||||
|
||||
|
||||
# -- Initialization tests ------------------------------------------------------
|
||||
|
||||
|
||||
def test_init_with_all_params(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
source_id="custom_source",
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
context_prompt="Custom prompt",
|
||||
update_delay=60,
|
||||
)
|
||||
assert provider.source_id == "custom_source"
|
||||
assert provider.project_client is mock_project_client
|
||||
assert provider.memory_store_name == "test_store"
|
||||
assert provider.scope == "user_123"
|
||||
assert provider.context_prompt == "Custom prompt"
|
||||
assert provider.update_delay == 60
|
||||
|
||||
|
||||
def test_init_default_source_id(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.source_id == FoundryMemoryProvider.DEFAULT_SOURCE_ID
|
||||
|
||||
|
||||
def test_init_default_context_prompt(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.context_prompt == FoundryMemoryProvider.DEFAULT_CONTEXT_PROMPT
|
||||
|
||||
|
||||
def test_init_default_update_delay(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.update_delay == 300
|
||||
|
||||
|
||||
def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMock, mock_credential: Mock) -> None:
|
||||
with patch("agent_framework_foundry._memory_provider.AIProjectClient") as mock_ai_project_client:
|
||||
mock_ai_project_client.return_value = mock_project_client
|
||||
provider = FoundryMemoryProvider(
|
||||
project_endpoint="https://test.project.endpoint",
|
||||
credential=mock_credential, # type: ignore[arg-type]
|
||||
allow_preview=True,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
assert provider.project_client is mock_project_client
|
||||
mock_ai_project_client.assert_called_once_with(
|
||||
endpoint="https://test.project.endpoint",
|
||||
credential=mock_credential,
|
||||
allow_preview=True,
|
||||
user_agent=get_user_agent(),
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_project_endpoint_without_project_client() -> None:
|
||||
with (
|
||||
patch("agent_framework_foundry._memory_provider.load_settings") as mock_load_settings,
|
||||
patch.dict(os.environ, {}, clear=True),
|
||||
pytest.raises(ValueError, match="project endpoint is required"),
|
||||
):
|
||||
mock_load_settings.return_value = {"project_endpoint": None}
|
||||
FoundryMemoryProvider(
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_credential_without_project_client() -> None:
|
||||
with pytest.raises(ValueError, match="Azure credential is required"):
|
||||
FoundryMemoryProvider(
|
||||
project_endpoint="https://test.project.endpoint",
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_memory_store_name(mock_project_client: AsyncMock) -> None:
|
||||
with pytest.raises(ValueError, match="memory_store_name is required"):
|
||||
FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="",
|
||||
scope="user_123",
|
||||
)
|
||||
|
||||
|
||||
def test_init_requires_scope(mock_project_client: AsyncMock) -> None:
|
||||
with pytest.raises(ValueError, match="scope is required"):
|
||||
FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="",
|
||||
)
|
||||
|
||||
|
||||
# -- before_run tests ----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_retrieves_static_memories_on_first_run(mock_project_client: AsyncMock) -> None:
|
||||
mem1 = Mock()
|
||||
mem1.memory_item.content = "User prefers Python"
|
||||
mem2 = Mock()
|
||||
mem2.memory_item.content = "User is based in Seattle"
|
||||
mock_search_result = Mock()
|
||||
mock_search_result.memories = [mem1, mem2]
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# Should call search_memories twice: once for static, once for contextual
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 2
|
||||
# Static memories should be cached
|
||||
assert len(session.state[provider.source_id]["static_memories"]) == 2
|
||||
assert session.state[provider.source_id]["initialized"] is True
|
||||
|
||||
|
||||
async def test_contextual_memories_added_to_context(mock_project_client: AsyncMock) -> None:
|
||||
# Mock static search (first call)
|
||||
static_mem = Mock()
|
||||
static_mem.memory_item.content = "User prefers Python"
|
||||
static_result = Mock()
|
||||
static_result.memories = [static_mem]
|
||||
|
||||
# Mock contextual search (second call)
|
||||
contextual_mem = Mock()
|
||||
contextual_mem.memory_item.content = "Last discussed async patterns"
|
||||
contextual_result = Mock()
|
||||
contextual_result.memories = [contextual_mem]
|
||||
contextual_result.search_id = "search-123"
|
||||
|
||||
mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result]
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# Check that memories were added to context
|
||||
assert provider.source_id in ctx.context_messages
|
||||
added = ctx.context_messages[provider.source_id]
|
||||
assert len(added) == 1
|
||||
assert "User prefers Python" in added[0].text # type: ignore[operator]
|
||||
assert "Last discussed async patterns" in added[0].text # type: ignore[operator]
|
||||
assert provider.context_prompt in added[0].text # type: ignore[operator]
|
||||
assert session.state[provider.source_id]["previous_search_id"] == "search-123"
|
||||
|
||||
|
||||
async def test_empty_input_skips_contextual_search(mock_project_client: AsyncMock) -> None:
|
||||
static_result = Mock()
|
||||
static_result.memories = []
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = static_result
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=[""])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# Should only call search_memories once for static memories
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 1
|
||||
assert provider.source_id not in ctx.context_messages
|
||||
|
||||
|
||||
async def test_empty_search_results_no_messages(mock_project_client: AsyncMock) -> None:
|
||||
mock_search_result = Mock()
|
||||
mock_search_result.memories = []
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = mock_search_result
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["test"])], session_id="s1")
|
||||
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
assert provider.source_id not in ctx.context_messages
|
||||
|
||||
|
||||
async def test_static_memories_only_retrieved_once(mock_project_client: AsyncMock) -> None:
|
||||
static_mem = Mock()
|
||||
static_mem.memory_item.content = "Static memory"
|
||||
static_result = Mock()
|
||||
static_result.memories = [static_mem]
|
||||
contextual_result = Mock()
|
||||
contextual_result.memories = []
|
||||
|
||||
mock_project_client.beta.memory_stores.search_memories.side_effect = [static_result, contextual_result]
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
# First call
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 2
|
||||
|
||||
# Reset mock for second call
|
||||
mock_project_client.beta.memory_stores.search_memories.reset_mock()
|
||||
contextual_result2 = Mock()
|
||||
contextual_result2.memories = []
|
||||
mock_project_client.beta.memory_stores.search_memories.return_value = contextual_result2
|
||||
|
||||
# Second call - should only search contextual, not static
|
||||
ctx2 = SessionContext(input_messages=[Message(role="user", contents=["World"])], session_id="s1")
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
assert mock_project_client.beta.memory_stores.search_memories.call_count == 1
|
||||
|
||||
|
||||
async def test_handles_search_exception_gracefully(mock_project_client: AsyncMock) -> None:
|
||||
mock_project_client.beta.memory_stores.search_memories.side_effect = Exception("API error")
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["Hello"])], session_id="s1")
|
||||
|
||||
# Should not raise exception
|
||||
await provider.before_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
# No memories added
|
||||
assert provider.source_id not in ctx.context_messages
|
||||
|
||||
|
||||
# -- after_run tests -----------------------------------------------------------
|
||||
|
||||
|
||||
async def test_stores_input_and_response(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller = Mock()
|
||||
mock_poller.update_id = "update-456"
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["question"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["answer"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.assert_awaited_once()
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
assert call_kwargs["name"] == "test_store"
|
||||
assert call_kwargs["scope"] == "user_123"
|
||||
assert len(call_kwargs["items"]) == 2
|
||||
assert call_kwargs["items"][0]["content"] == "question"
|
||||
assert call_kwargs["items"][1]["content"] == "answer"
|
||||
assert session.state[provider.source_id]["previous_update_id"] == "update-456"
|
||||
|
||||
|
||||
async def test_only_stores_user_assistant_system(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller = Mock()
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(
|
||||
input_messages=[
|
||||
Message(role="user", contents=["hello"]),
|
||||
Message(role="tool", contents=["tool output"]),
|
||||
],
|
||||
session_id="s1",
|
||||
)
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["reply"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
items = call_kwargs["items"]
|
||||
assert len(items) == 2
|
||||
assert items[0]["content"] == "hello"
|
||||
assert items[1]["content"] == "reply"
|
||||
|
||||
|
||||
async def test_skips_empty_messages(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(
|
||||
input_messages=[
|
||||
Message(role="user", contents=[""]),
|
||||
Message(role="user", contents=[" "]),
|
||||
],
|
||||
session_id="s1",
|
||||
)
|
||||
ctx._response = AgentResponse(messages=[])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_uses_configured_update_delay(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller = Mock()
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.return_value = mock_poller
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
update_delay=60,
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
assert call_kwargs["update_delay"] == 60
|
||||
|
||||
|
||||
async def test_uses_previous_update_id_for_incremental_updates(mock_project_client: AsyncMock) -> None:
|
||||
mock_poller1 = Mock()
|
||||
mock_poller1.update_id = "update-1"
|
||||
mock_poller2 = Mock()
|
||||
mock_poller2.update_id = "update-2"
|
||||
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.side_effect = [mock_poller1, mock_poller2]
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx1 = SessionContext(input_messages=[Message(role="user", contents=["first"])], session_id="s1")
|
||||
ctx1._response = AgentResponse(messages=[Message(role="assistant", contents=["response1"])])
|
||||
|
||||
# First update
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx1, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
assert session.state[provider.source_id]["previous_update_id"] == "update-1"
|
||||
|
||||
# Second update should use previous_update_id
|
||||
ctx2 = SessionContext(input_messages=[Message(role="user", contents=["second"])], session_id="s1")
|
||||
ctx2._response = AgentResponse(messages=[Message(role="assistant", contents=["response2"])])
|
||||
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx2, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
call_kwargs = mock_project_client.beta.memory_stores.begin_update_memories.call_args.kwargs
|
||||
assert call_kwargs["previous_update_id"] == "update-1"
|
||||
assert session.state[provider.source_id]["previous_update_id"] == "update-2"
|
||||
|
||||
|
||||
async def test_handles_update_exception_gracefully(mock_project_client: AsyncMock) -> None:
|
||||
mock_project_client.beta.memory_stores.begin_update_memories.side_effect = Exception("API error")
|
||||
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
session = AgentSession(session_id="test-session")
|
||||
ctx = SessionContext(input_messages=[Message(role="user", contents=["hi"])], session_id="s1")
|
||||
ctx._response = AgentResponse(messages=[Message(role="assistant", contents=["hey"])])
|
||||
|
||||
# Should not raise exception
|
||||
await provider.after_run( # type: ignore[arg-type]
|
||||
agent=cast(Any, None), session=session, context=ctx, state=session.state.setdefault(provider.source_id, {})
|
||||
)
|
||||
|
||||
|
||||
# -- Context manager tests -----------------------------------------------------
|
||||
|
||||
|
||||
async def test_aenter_delegates_to_client(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
result = await provider.__aenter__()
|
||||
assert result is provider
|
||||
mock_project_client.__aenter__.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_aexit_delegates_to_client(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
await provider.__aexit__(None, None, None)
|
||||
mock_project_client.__aexit__.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_async_with_syntax(mock_project_client: AsyncMock) -> None:
|
||||
provider = FoundryMemoryProvider(
|
||||
project_client=mock_project_client,
|
||||
memory_store_name="test_store",
|
||||
scope="user_123",
|
||||
)
|
||||
async with provider as p:
|
||||
assert p is provider
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
|
||||
|
||||
# region _validate_consent_link tests
|
||||
|
||||
|
||||
def test_validate_consent_link_accepts_valid_https() -> None:
|
||||
"""A valid HTTPS URL with a netloc passes validation."""
|
||||
link = "https://consent.example.com/auth?code=123"
|
||||
assert _validate_consent_link(link, "item-1") == link
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTP link is rejected and a warning is logged."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("http://insecure.example.com/login", "item-2")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-2" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with an empty netloc (e.g. https:///path) is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("https:///path", "item-3")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-3" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-URL string is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("not-a-url", "item-4")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region try_parse_oauth_consent_event tests
|
||||
|
||||
|
||||
def _make_output_item_event(
|
||||
*,
|
||||
item_type: str = "oauth_consent_request",
|
||||
consent_link: Any = "https://consent.example.com/auth",
|
||||
item_id: str = "oauth-item-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.output_item.added`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_item.added"
|
||||
item = MagicMock()
|
||||
item.type = item_type
|
||||
item.consent_link = consent_link
|
||||
item.id = item_id
|
||||
event.item = item
|
||||
return event
|
||||
|
||||
|
||||
def _make_top_level_event(
|
||||
*,
|
||||
consent_link: Any = "https://consent.example.com/authorize",
|
||||
event_id: str = "consent-event-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.oauth_consent_requested`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.oauth_consent_requested"
|
||||
event.consent_link = consent_link
|
||||
event.id = event_id
|
||||
return event
|
||||
|
||||
|
||||
def test_returns_none_for_unrelated_event() -> None:
|
||||
"""An event with a non-oauth type returns None."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_text.delta"
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_returns_none_for_event_without_type() -> None:
|
||||
"""An event object missing a 'type' attribute returns None."""
|
||||
event = object() # no type attribute
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_parses_output_item_added_with_valid_link() -> None:
|
||||
"""A response.output_item.added event with a valid HTTPS link produces Content."""
|
||||
event = _make_output_item_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert update.role == "assistant"
|
||||
assert update.model == "test-model"
|
||||
assert update.raw_representation is event
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/auth"
|
||||
|
||||
|
||||
def test_parses_top_level_consent_requested_event() -> None:
|
||||
"""A response.oauth_consent_requested event produces Content."""
|
||||
event = _make_top_level_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/authorize"
|
||||
|
||||
|
||||
def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-HTTPS consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A None consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link=None, item_id="item-none")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An empty-string consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="", item_id="item-empty")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with empty netloc (https:///path) is rejected."""
|
||||
event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -0,0 +1,664 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from agent_framework import Agent, MCPStdioTool, tool
|
||||
from agent_framework._feature_stage import ExperimentalFeature
|
||||
from azure.ai.projects.models import (
|
||||
CodeInterpreterTool,
|
||||
PromptAgentDefinition,
|
||||
PromptAgentDefinitionTextOptions,
|
||||
RaiConfig,
|
||||
Reasoning,
|
||||
StructuredInputDefinition,
|
||||
ToolChoiceAllowed,
|
||||
ToolChoiceFunction,
|
||||
WebSearchTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
FunctionTool as ProjectsFunctionTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
MCPTool as FoundryMCPTool,
|
||||
)
|
||||
from azure.ai.projects.models import (
|
||||
Tool as ProjectsTool,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework_foundry import (
|
||||
FoundryChatClient,
|
||||
RawFoundryChatClient,
|
||||
to_prompt_agent,
|
||||
)
|
||||
|
||||
|
||||
@tool
|
||||
def get_weather(location: Annotated[str, "City name"]) -> str:
|
||||
"""Get the weather for a location."""
|
||||
return f"sunny in {location}"
|
||||
|
||||
|
||||
def _make_foundry_chat_client(model: str | None = "gpt-4o-mini") -> FoundryChatClient:
|
||||
"""Build a FoundryChatClient backed by a mocked project client."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
return FoundryChatClient(project_client=mock_project, model=model or "placeholder")
|
||||
|
||||
|
||||
def _make_agent(client: Any, **agent_kwargs: Any) -> Agent:
|
||||
"""Build an Agent without entering the async context manager."""
|
||||
return Agent(client=client, **agent_kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core conversion: model resolution and client-type guarding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_minimal() -> None:
|
||||
"""An agent with only model + instructions produces a valid PromptAgentDefinition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Be helpful.")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition, PromptAgentDefinition)
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions == "Be helpful."
|
||||
assert definition.tools is None
|
||||
|
||||
|
||||
def test_to_prompt_agent_serializes_cleanly() -> None:
|
||||
"""The PromptAgentDefinition serializes to a dict that includes ``kind: prompt``."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="Hi.")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert payload["model"] == "gpt-4o-mini"
|
||||
assert payload["instructions"] == "Hi."
|
||||
assert payload["kind"] == "prompt"
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_non_foundry_client() -> None:
|
||||
"""A non-FoundryChatClient client raises TypeError."""
|
||||
|
||||
class NotFoundryChatClient:
|
||||
"""Stand-in for a different chat client implementation."""
|
||||
|
||||
agent = _make_agent(NotFoundryChatClient())
|
||||
|
||||
with pytest.raises(TypeError, match="FoundryChatClient"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_missing_model() -> None:
|
||||
"""When neither default_options nor the client has a model, ValueError is raised."""
|
||||
client = _make_foundry_chat_client()
|
||||
client.model = ""
|
||||
agent = _make_agent(client)
|
||||
agent.default_options.pop("model", None)
|
||||
|
||||
with pytest.raises(ValueError, match="Agent has no model"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_no_instructions() -> None:
|
||||
"""A tool-only agent (no instructions) produces a definition with instructions=None."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
tools=[WebSearchTool()],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o-mini"
|
||||
assert definition.instructions is None
|
||||
payload = definition.as_dict()
|
||||
assert "instructions" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_prefers_default_options_model() -> None:
|
||||
"""default_options['model'] wins over the bound client's model."""
|
||||
client = _make_foundry_chat_client(model="client-model")
|
||||
agent = _make_agent(client, instructions="x", default_options={"model": "agent-override"})
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "agent-override"
|
||||
|
||||
|
||||
def test_to_prompt_agent_falls_back_to_client_model() -> None:
|
||||
"""When the agent has no model override, the bound client's model is used."""
|
||||
agent = _make_agent(_make_foundry_chat_client(model="client-model"), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "client-model"
|
||||
|
||||
|
||||
def test_to_prompt_agent_works_with_raw_foundry_chat_client() -> None:
|
||||
"""to_prompt_agent accepts subclasses too — RawFoundryChatClient works."""
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
raw_client = RawFoundryChatClient(project_client=mock_project, model="gpt-4o")
|
||||
agent = _make_agent(raw_client, instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.model == "gpt-4o"
|
||||
|
||||
|
||||
def test_to_prompt_agent_is_marked_experimental() -> None:
|
||||
"""to_prompt_agent carries the TO_PROMPT_AGENT experimental metadata."""
|
||||
assert getattr(to_prompt_agent, "__feature_stage__", None) == "experimental"
|
||||
assert getattr(to_prompt_agent, "__feature_id__", None) == ExperimentalFeature.TO_PROMPT_AGENT.value
|
||||
|
||||
|
||||
def test_to_prompt_agent_does_not_mutate_default_options() -> None:
|
||||
"""Conversion never mutates the translatable option values in ``agent.default_options``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.5,
|
||||
"reasoning": {"effort": "low"},
|
||||
"response_format": {"type": "json_object"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
reasoning_before = dict(agent.default_options["reasoning"]) # type: ignore[index]
|
||||
response_format_before = dict(agent.default_options["response_format"]) # type: ignore[index]
|
||||
tool_choice_before = agent.default_options.get("tool_choice")
|
||||
|
||||
to_prompt_agent(agent)
|
||||
|
||||
assert dict(agent.default_options["reasoning"]) == reasoning_before # type: ignore[index]
|
||||
assert dict(agent.default_options["response_format"]) == response_format_before # type: ignore[index]
|
||||
assert agent.default_options.get("tool_choice") == tool_choice_before
|
||||
assert "text" not in agent.default_options
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_sdk_tool_instances() -> None:
|
||||
"""Foundry SDK tool instances (e.g. WebSearchTool) are passed through unchanged."""
|
||||
ws = WebSearchTool()
|
||||
ci = CodeInterpreterTool({"container": {"type": "auto"}})
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[ws, ci])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 2
|
||||
assert definition.tools[0] is ws
|
||||
assert definition.tools[1] is ci
|
||||
|
||||
|
||||
def test_to_prompt_agent_converts_function_tool() -> None:
|
||||
"""An AF FunctionTool from @tool emerges as a Foundry FunctionTool declaration."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
fn = definition.tools[0]
|
||||
assert isinstance(fn, ProjectsFunctionTool)
|
||||
assert fn.name == "get_weather"
|
||||
assert fn.description == "Get the weather for a location."
|
||||
assert fn.strict is True
|
||||
parameters = fn.parameters
|
||||
assert parameters["type"] == "object"
|
||||
assert "location" in parameters["properties"]
|
||||
assert parameters["required"] == ["location"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_preserves_mixed_tool_order() -> None:
|
||||
"""A mix of hosted SDK tools and function tools is preserved in definition order."""
|
||||
ws = WebSearchTool()
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[ws, get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert definition.tools[0] is ws
|
||||
assert isinstance(definition.tools[1], ProjectsFunctionTool)
|
||||
assert definition.tools[1].name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_hosted_mcp_tool() -> None:
|
||||
"""A hosted MCP tool from FoundryChatClient.get_mcp_tool() is passed through."""
|
||||
hosted_mcp = FoundryChatClient.get_mcp_tool(
|
||||
name="github",
|
||||
url="https://mcp.example.com",
|
||||
)
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[hosted_mcp])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
assert isinstance(definition.tools[0], FoundryMCPTool)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_local_mcp_tool() -> None:
|
||||
"""A local MCP tool in agent.mcp_tools raises a ValueError pointing at get_mcp_tool."""
|
||||
local_mcp = MCPStdioTool(name="local_fs", command="echo")
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[local_mcp])
|
||||
|
||||
with pytest.raises(ValueError, match="get_mcp_tool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_unknown_tool_type() -> None:
|
||||
"""An arbitrary object in tools that isn't a known shape raises ValueError."""
|
||||
|
||||
class NotATool:
|
||||
pass
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[NotATool()],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="NotATool"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_tool() -> None:
|
||||
"""A dict with a 'type' discriminator is rehydrated through the SDK Tool model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"type": "web_search"}],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
# The SDK discriminator on ``type`` should materialize the concrete subclass
|
||||
# (here ``WebSearchTool``), not a generic ``Tool``.
|
||||
assert isinstance(tool_obj, WebSearchTool)
|
||||
assert isinstance(tool_obj, ProjectsTool)
|
||||
assert tool_obj.type == "web_search"
|
||||
|
||||
|
||||
def test_to_prompt_agent_accepts_dict_function_tool() -> None:
|
||||
"""A dict with ``type='function'`` rehydrates to a Foundry ``FunctionTool``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Look up a value.",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tools is not None
|
||||
assert len(definition.tools) == 1
|
||||
tool_obj = definition.tools[0]
|
||||
assert isinstance(tool_obj, ProjectsFunctionTool)
|
||||
assert tool_obj.name == "lookup"
|
||||
assert tool_obj.description == "Look up a value."
|
||||
|
||||
|
||||
def test_to_prompt_agent_rejects_dict_tool_without_type() -> None:
|
||||
"""A dict missing the 'type' field raises ValueError."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[{"name": "missing_type"}],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="type"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generation parameters sourced from default_options
|
||||
# (translated by _prepare_prompt_agent_options in _to_prompt_agent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_top_p_unset_by_default() -> None:
|
||||
"""Without default_options entries, temperature/top_p are unset on the definition."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature is None
|
||||
assert definition.top_p is None
|
||||
payload = definition.as_dict()
|
||||
assert "temperature" not in payload
|
||||
assert "top_p" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_temperature_top_p_from_default_options() -> None:
|
||||
"""temperature/top_p in default_options flow through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.42, "top_p": 0.8},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.42
|
||||
assert definition.top_p == 0.8
|
||||
|
||||
|
||||
def test_to_prompt_agent_temperature_zero_is_honored() -> None:
|
||||
"""A literal ``0.0`` in default_options is treated as explicit, not as unset."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"temperature": 0.0, "top_p": 0.0},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.temperature == 0.0
|
||||
assert definition.top_p == 0.0
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_omitted_when_no_tools() -> None:
|
||||
"""``tool_choice`` is dropped when the definition has no tools.
|
||||
|
||||
Mirrors RawOpenAIChatClient._prepare_options behavior. This also keeps
|
||||
Agent.__init__'s default ``tool_choice="auto"`` from polluting tool-less
|
||||
prompt agents.
|
||||
"""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice is None
|
||||
assert "tool_choice" not in definition.as_dict()
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_with_tools() -> None:
|
||||
"""When tools are present, the default ``tool_choice="auto"`` flows through."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x", tools=[get_weather])
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "auto"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_string_with_tools() -> None:
|
||||
"""A string ``tool_choice="required"`` flows through when tools are present."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={"tool_choice": "required"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.tool_choice == "required"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_required_function_dict() -> None:
|
||||
"""tool_choice mode=required with a function name → ToolChoiceFunction."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "required", "required_function_name": "get_weather"},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceFunction)
|
||||
assert definition.tool_choice.name == "get_weather"
|
||||
|
||||
|
||||
def test_to_prompt_agent_tool_choice_auto_allowed_tools() -> None:
|
||||
"""tool_choice mode=auto with allowed_tools → ToolChoiceAllowed."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
tools=[get_weather],
|
||||
default_options={
|
||||
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.tool_choice, ToolChoiceAllowed)
|
||||
assert definition.tool_choice.mode == "auto"
|
||||
assert definition.tool_choice.tools == [{"type": "function", "name": "get_weather"}]
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_dict_from_default_options() -> None:
|
||||
"""A reasoning dict in default_options becomes a Foundry ``Reasoning`` model."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": {"effort": "high", "summary": "concise"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "high"
|
||||
assert definition.reasoning.summary == "concise"
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_reasoning_model_from_default_options() -> None:
|
||||
"""A pre-built ``Reasoning`` model in default_options is passed through."""
|
||||
reasoning = Reasoning(effort="medium")
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"reasoning": reasoning},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert definition.reasoning is reasoning
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_dict_to_text() -> None:
|
||||
"""A ``response_format`` dict in default_options becomes ``text.format``."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "weather",
|
||||
"schema": {"type": "object", "properties": {"temp": {"type": "number"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "weather"
|
||||
assert format_dict["schema"] == {"type": "object", "properties": {"temp": {"type": "number"}}}
|
||||
|
||||
|
||||
def test_to_prompt_agent_lifts_response_format_pydantic_to_text() -> None:
|
||||
"""A Pydantic ``BaseModel`` response_format becomes ``text.format`` json_schema."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
condition: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"response_format": WeatherReply},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
format_dict = definition.text["format"]
|
||||
assert format_dict is not None
|
||||
assert format_dict["type"] == "json_schema"
|
||||
assert format_dict["name"] == "WeatherReply"
|
||||
assert "schema" in format_dict
|
||||
assert "location" in format_dict["schema"]["properties"]
|
||||
|
||||
|
||||
def test_to_prompt_agent_merges_verbosity_into_text() -> None:
|
||||
"""A ``verbosity`` entry merges into the ``text`` config."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"verbosity": "low"},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
# PromptAgentDefinitionTextOptions only declares ``format``, but its
|
||||
# mapping-init preserves extra keys for server-side use.
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
|
||||
|
||||
def test_to_prompt_agent_raises_on_conflicting_response_format_and_text_format() -> None:
|
||||
"""Pydantic ``response_format`` + a different ``text.format`` mapping must fail loudly."""
|
||||
|
||||
class WeatherReply(BaseModel):
|
||||
location: str
|
||||
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"response_format": WeatherReply,
|
||||
"text": {"format": {"type": "json_object"}},
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Conflicting response_format"):
|
||||
to_prompt_agent(agent)
|
||||
|
||||
|
||||
def test_to_prompt_agent_passes_through_text_dict_from_default_options() -> None:
|
||||
"""A ``text`` dict in default_options flows through to the definition."""
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={"text": {"format": {"type": "text"}, "verbosity": "high"}},
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(agent)
|
||||
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert definition.text["format"] == {"type": "text"}
|
||||
assert dict(definition.text).get("verbosity") == "high"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Foundry-specific kwargs (no AF ChatOptions equivalent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_kwarg_only_fields_unset_by_default() -> None:
|
||||
"""structured_inputs and rai_config are absent from the payload when unset."""
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
payload = to_prompt_agent(agent).as_dict()
|
||||
|
||||
assert "structured_inputs" not in payload
|
||||
assert "rai_config" not in payload
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_structured_inputs_kwarg() -> None:
|
||||
"""A ``structured_inputs`` mapping is forwarded (and copied to a new dict)."""
|
||||
inputs = {"city": StructuredInputDefinition(description="Target city.")}
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, structured_inputs=inputs)
|
||||
|
||||
assert definition.structured_inputs is not None
|
||||
assert set(definition.structured_inputs) == {"city"}
|
||||
assert definition.structured_inputs["city"] is inputs["city"]
|
||||
inputs["other"] = StructuredInputDefinition(description="x")
|
||||
assert "other" not in definition.structured_inputs
|
||||
|
||||
|
||||
def test_to_prompt_agent_forwards_rai_config_kwarg() -> None:
|
||||
"""A ``RaiConfig`` kwarg is forwarded to the definition."""
|
||||
rai_config = RaiConfig(rai_policy_name="test-policy")
|
||||
agent = _make_agent(_make_foundry_chat_client(), instructions="x")
|
||||
|
||||
definition = to_prompt_agent(agent, rai_config=rai_config)
|
||||
|
||||
assert definition.rai_config is rai_config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Combined integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_to_prompt_agent_combines_all_sources() -> None:
|
||||
"""Generation params from default_options + Foundry-only kwargs combine cleanly."""
|
||||
rai_config = RaiConfig(rai_policy_name="test-policy")
|
||||
structured = {"q": StructuredInputDefinition(description="query")}
|
||||
agent = _make_agent(
|
||||
_make_foundry_chat_client(),
|
||||
instructions="x",
|
||||
default_options={
|
||||
"temperature": 0.3,
|
||||
"top_p": 0.95,
|
||||
"tool_choice": "auto",
|
||||
"reasoning": {"effort": "medium"},
|
||||
"verbosity": "low",
|
||||
},
|
||||
tools=[get_weather],
|
||||
)
|
||||
|
||||
definition = to_prompt_agent(
|
||||
agent,
|
||||
structured_inputs=structured,
|
||||
rai_config=rai_config,
|
||||
)
|
||||
|
||||
assert definition.temperature == 0.3
|
||||
assert definition.top_p == 0.95
|
||||
assert definition.tool_choice == "auto"
|
||||
assert isinstance(definition.reasoning, Reasoning)
|
||||
assert definition.reasoning.effort == "medium"
|
||||
assert isinstance(definition.text, PromptAgentDefinitionTextOptions)
|
||||
assert dict(definition.text).get("verbosity") == "low"
|
||||
assert definition.rai_config is rai_config
|
||||
assert definition.structured_inputs is not None and "q" in definition.structured_inputs
|
||||
assert definition.tools is not None and len(definition.tools) == 1
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user