Python: add feature-usage User-Agent telemetry (#7420)

* Python: add first-pass feature usage telemetry

Add the 128-bit feature accumulator, package-local indexes, activation markers, and destination-scoped User-Agent emission for the initial Python implementation slice.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: track declarative feature usage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: complete feature usage telemetry

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: report core version in User-Agent

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: configure Lab telemetry import path

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: preserve telemetry transport behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: preserve caller-owned Foundry transports

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

* Python: remove stale Anthropic test import

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f

---------

Copilot-Session: 346bf168-b668-4c4a-a8db-a67282ee5e5f
This commit is contained in:
Eduard van Valkenburg
2026-07-30 12:24:34 +02:00
committed by GitHub
parent 962b86ddbb
commit b64a2e2f82
154 changed files with 1623 additions and 74 deletions
@@ -19,8 +19,10 @@ from agent_framework import (
Message,
SupportsAgentRun,
)
from agent_framework._telemetry import mark_feature_used
from typing_extensions import override
from ._feature_usage import FeatureIndex
from ._utils import get_uri_data
logger = logging.getLogger("agent_framework.a2a")
@@ -147,6 +149,7 @@ class A2AExecutor(AgentExecutor):
if context.message is None:
raise ValueError("Message must be provided in the RequestContext")
mark_feature_used(FeatureIndex.A2A)
query = context.get_user_input()
task = context.current_task
@@ -42,10 +42,12 @@ from agent_framework import (
normalize_messages,
prepend_agent_framework_to_user_agent,
)
from agent_framework._telemetry import mark_feature_used
from agent_framework._types import AgentRunInputs
from agent_framework.observability import AgentTelemetryLayer
from google.protobuf.json_format import MessageToDict
from ._feature_usage import FeatureIndex
from ._utils import get_uri_data
if sys.version_info >= (3, 11):
@@ -542,6 +544,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
session: The agent session for context providers.
session_context: The session context for context providers.
"""
mark_feature_used(FeatureIndex.A2A)
if session_context is None:
session_context = SessionContext(input_messages=[], options={})
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""A2A-owned feature-usage indexes."""
A2A = 71
@@ -7,9 +7,11 @@ from typing import Any, cast
from ag_ui.core import BaseEvent
from agent_framework import SupportsAgentRun
from agent_framework._telemetry import mark_feature_used
from ._agent_run import PendingApprovalEntry, PendingApprovalKey, run_agent_stream
from ._approval_state import InMemoryAGUIApprovalStateStore
from ._feature_usage import FeatureIndex
from ._snapshots import AGUIThreadSnapshotStore
@@ -142,6 +144,7 @@ class AgentFrameworkAgent:
Yields:
AG-UI events
"""
mark_feature_used(FeatureIndex.AG_UI)
async for event in run_agent_stream(
input_data,
self.agent,
@@ -24,10 +24,12 @@ from agent_framework import (
ResponseStream,
)
from agent_framework._middleware import ChatMiddlewareLayer
from agent_framework._telemetry import mark_feature_used
from agent_framework._tools import FunctionInvocationConfiguration, FunctionInvocationLayer
from agent_framework.observability import ChatTelemetryLayer
from ._event_converters import AGUIEventConverter
from ._feature_usage import FeatureIndex
from ._http_service import AGUIHttpService, _serialize_available_interrupts, _serialize_resume
from ._message_adapters import agent_framework_messages_to_agui
from ._utils import convert_tools_to_agui_format
@@ -398,6 +400,7 @@ class AGUIChatClient(
Yields:
ChatResponseUpdate objects
"""
mark_feature_used(FeatureIndex.AG_UI)
messages_to_send, state = self._extract_state_from_messages(messages)
thread_id = self._get_thread_id(options)
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""AG-UI-owned feature-usage indexes."""
AG_UI = 72
@@ -25,7 +25,9 @@ from ag_ui.core import (
ToolCallStartEvent,
)
from agent_framework import Workflow
from agent_framework._telemetry import mark_feature_used
from ._feature_usage import FeatureIndex
from ._message_adapters import agui_messages_to_snapshot_format
from ._run_common import (
_build_run_finished_event,
@@ -298,6 +300,7 @@ class AgentFrameworkWorkflow:
Subclasses may override this to provide custom AG-UI streams.
"""
mark_feature_used(FeatureIndex.AG_UI)
thread_id = self._thread_id_from_input(input_data)
run_id = str(input_data.get("run_id") or input_data.get("runId") or uuid.uuid4())
snapshot_scope = cast(str | None, input_data.get(_SNAPSHOT_SCOPE_INPUT_KEY))
@@ -28,7 +28,7 @@ from agent_framework import (
tool,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework._tools import SHELL_TOOL_KIND_VALUE, normalize_tools
from agent_framework._types import _get_data_bytes_as_str # type: ignore
from agent_framework.observability import ChatTelemetryLayer
@@ -55,6 +55,8 @@ from anthropic.types.beta.beta_code_execution_tool_result_error import (
from anthropic.types.beta.beta_encrypted_code_execution_result_block import BetaEncryptedCodeExecutionResultBlock
from pydantic import BaseModel
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 11):
from typing import TypedDict # pragma: no cover
else:
@@ -550,6 +552,7 @@ class RawAnthropicClient(
# each message_delta carries the running total), so thread a per-stream
# accumulator to _process_stream_event to emit increments instead.
emitted_usage: dict[str, int] = {}
mark_feature_used(FeatureIndex.ANTHROPIC)
async for chunk in await self.anthropic_client.beta.messages.create(**run_options, stream=True):
parsed_chunk = self._process_stream_event(chunk, emitted_usage)
if parsed_chunk:
@@ -559,6 +562,7 @@ class RawAnthropicClient(
# Non-streaming mode
async def _get_response() -> ChatResponse:
mark_feature_used(FeatureIndex.ANTHROPIC)
message = await self.anthropic_client.beta.messages.create(**run_options, stream=False)
return self._process_message(message, options)
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Anthropic-owned feature-usage indexes."""
ANTHROPIC = 57
@@ -16,7 +16,6 @@ from agent_framework import (
FunctionInvocationLayer,
Message,
SupportsChatGetResponse,
UsageDetails,
tool,
)
from agent_framework._settings import load_settings
@@ -33,6 +32,7 @@ from pydantic import BaseModel, Field
from agent_framework_anthropic import AnthropicClient, RawAnthropicClient
from agent_framework_anthropic._chat_client import AnthropicSettings
from agent_framework_anthropic._feature_usage import FeatureIndex
# Test constants
VALID_PNG_BASE64 = b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
@@ -1606,10 +1606,12 @@ async def test_inner_get_response(mock_anthropic_client: MagicMock) -> None:
messages = [Message(role="user", contents=["Hi"])]
chat_options = ChatOptions(max_tokens=10)
response = await client._inner_get_response( # type: ignore[attr-defined]
messages=messages, options=chat_options
)
with patch("agent_framework_anthropic._chat_client.mark_feature_used") as mark_feature_used:
response = await client._inner_get_response( # type: ignore[attr-defined]
messages=messages, options=chat_options
)
mark_feature_used.assert_called_once_with(FeatureIndex.ANTHROPIC)
assert response is not None
assert response.response_id == "msg_test"
assert len(response.messages) == 1
@@ -25,7 +25,7 @@ from agent_framework import (
SupportsGetEmbeddings,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework.exceptions import SettingNotFoundError
from azure.core.credentials import AzureKeyCredential, TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -47,6 +47,8 @@ from azure.search.documents.models import (
VectorizedQuery,
)
from ._feature_usage import FeatureIndex
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
from azure.search.documents.knowledgebases.aio import KnowledgeBaseRetrievalClient
@@ -626,6 +628,7 @@ class AzureAISearchContextProvider(ContextProvider):
state: dict[str, Any],
) -> None:
"""Retrieve relevant context from Azure AI Search and add to session context."""
mark_feature_used(FeatureIndex.AZURE_AI_SEARCH)
messages_list = list(context.input_messages)
filtered_messages = [
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Azure AI Search-owned feature-usage indexes."""
AZURE_AI_SEARCH = 65
@@ -20,6 +20,7 @@ from agent_framework_azure_ai_search._context_provider import (
KnowledgeBaseOutputModeLiteral,
RetrievalReasoningEffortLiteral,
)
from agent_framework_azure_ai_search._feature_usage import FeatureIndex
# -- Helpers -------------------------------------------------------------------
@@ -54,6 +55,17 @@ class MockSearchResults:
return doc
async def test_before_run_marks_azure_ai_search_used() -> None:
provider = object.__new__(AzureAISearchContextProvider)
context = Mock(spec=SessionContext)
context.input_messages = []
with patch("agent_framework_azure_ai_search._context_provider.mark_feature_used") as mark_feature_used:
await provider.before_run(agent=Mock(), session=Mock(spec=AgentSession), context=context, state={})
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_AI_SEARCH)
def _make_mock_index(
fields: list[SimpleNamespace] | None = None,
profiles: list[SimpleNamespace] | None = None,
@@ -29,12 +29,15 @@ from agent_framework import (
)
from agent_framework._sessions import AgentSession
from agent_framework._settings import load_settings
from agent_framework._telemetry import mark_feature_used
from azure.ai.contentunderstanding import to_llm_input
from azure.ai.contentunderstanding.aio import ContentUnderstandingClient
from azure.ai.contentunderstanding.models import AnalysisInput, AnalysisResult
from azure.core.credentials import AzureKeyCredential
from azure.core.credentials_async import AsyncTokenCredential
from ._feature_usage import FeatureIndex
if TYPE_CHECKING:
from agent_framework._agents import SupportsAgentRun
@@ -275,6 +278,7 @@ class ContentUnderstandingContextProvider(ContextProvider):
This method is called automatically by the framework before each LLM invocation.
"""
mark_feature_used(FeatureIndex.AZURE_CONTENTUNDERSTANDING)
documents: dict[str, DocumentEntry] = state.setdefault("documents", {})
# Per-session mutable state — isolated per session to prevent cross-session leakage.
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Azure Content Understanding-owned feature-usage indexes."""
AZURE_CONTENTUNDERSTANDING = 67
@@ -7,7 +7,7 @@ import base64
import json
import re
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock
from unittest.mock import AsyncMock, MagicMock, patch
from agent_framework import Content, Message, SessionContext
from agent_framework._sessions import AgentSession
@@ -18,6 +18,7 @@ from agent_framework_azure_contentunderstanding import (
DocumentStatus,
)
from agent_framework_azure_contentunderstanding._detection import SUPPORTED_MEDIA_TYPES, derive_doc_key
from agent_framework_azure_contentunderstanding._feature_usage import FeatureIndex
# ---------------------------------------------------------------------------
# Helpers
@@ -983,7 +984,12 @@ class TestErrorHandling:
state: dict[str, Any] = {}
session = AgentSession()
await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state)
with patch(
"agent_framework_azure_contentunderstanding._context_provider.mark_feature_used"
) as mark_feature_used:
await provider.before_run(agent=_make_mock_agent(), session=session, context=context, state=state)
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_CONTENTUNDERSTANDING)
# Client should still be set
assert provider._client is not None
@@ -17,6 +17,9 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, cast
from agent_framework import AgentSession, ContextProvider, Message, SessionContext
from agent_framework._settings import load_settings
from agent_framework._telemetry import mark_feature_used
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
@@ -344,6 +347,8 @@ class CosmosMemoryContextProvider(ContextProvider):
context: The invocation context to add memories to.
state: Provider-scoped mutable state.
"""
mark_feature_used(FeatureIndex.AZURE_COSMOS_MEMORY)
# Extract query from input messages
query_text = "\n".join(msg.text for msg in context.input_messages if msg.text and msg.text.strip())
@@ -424,6 +429,8 @@ class CosmosMemoryContextProvider(ContextProvider):
context: The invocation context with response populated.
state: Provider-scoped mutable state.
"""
mark_feature_used(FeatureIndex.AZURE_COSMOS_MEMORY)
# Get user_id and thread_id from provider-scoped state (falling back to the session id)
user_id = self._resolve_user_id(state, session)
thread_id = state.get("thread_id") or session.session_id or "default"
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Azure Cosmos DB memory-owned feature-usage indexes."""
AZURE_COSMOS_MEMORY = 82
@@ -23,12 +23,29 @@ from agent_framework_azure_cosmos_memory._context_provider import (
DEFAULT_CONTEXT_PROMPT,
CosmosMemoryContextProvider,
)
from agent_framework_azure_cosmos_memory._feature_usage import FeatureIndex
# The provider methods accept an ``agent`` implementing ``SupportsAgentRun`` but never
# use it in these tests, so a typed ``None`` stub keeps the call sites clean.
_STUB_AGENT: Any = None
async def test_before_run_marks_cosmos_memory_used_before_empty_return() -> None:
provider = object.__new__(CosmosMemoryContextProvider)
context = MagicMock(spec=SessionContext)
context.input_messages = []
with patch("agent_framework_azure_cosmos_memory._context_provider.mark_feature_used") as mark_feature_used:
await provider.before_run(
agent=_STUB_AGENT,
session=MagicMock(spec=AgentSession),
context=context,
state={},
)
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_COSMOS_MEMORY)
@pytest.fixture
def mock_memory_client() -> AsyncMock:
"""Create a mock AsyncCosmosMemoryClient."""
@@ -8,7 +8,7 @@ import logging
from typing import Any, TypedDict
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from agent_framework.exceptions import WorkflowCheckpointException
@@ -18,6 +18,8 @@ from azure.cosmos import PartitionKey
from azure.cosmos.aio import ContainerProxy, CosmosClient
from azure.cosmos.exceptions import CosmosResourceNotFoundError
from ._feature_usage import FeatureIndex
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
logger = logging.getLogger(__name__)
@@ -214,6 +216,7 @@ class CosmosCheckpointStorage:
Returns:
The unique ID of the saved checkpoint.
"""
mark_feature_used(FeatureIndex.AZURE_COSMOS)
await self._ensure_container_proxy()
checkpoint_dict = checkpoint.to_dict()
@@ -242,6 +245,7 @@ class CosmosCheckpointStorage:
WorkflowCheckpointException: If no checkpoint with the given ID exists,
or if multiple checkpoints share the same ID across workflows.
"""
mark_feature_used(FeatureIndex.AZURE_COSMOS)
await self._ensure_container_proxy()
query = "SELECT * FROM c WHERE c.checkpoint_id = @checkpoint_id"
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Azure Cosmos DB-owned feature-usage indexes."""
AZURE_COSMOS = 66
@@ -13,12 +13,14 @@ from typing import Any, ClassVar, TypedDict
from agent_framework import Message
from agent_framework._sessions import HistoryProvider
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.cosmos import PartitionKey
from azure.cosmos.aio import ContainerProxy, CosmosClient, DatabaseProxy
from ._feature_usage import FeatureIndex
AzureCredentialTypes = TokenCredential | AsyncTokenCredential
logger = logging.getLogger(__name__)
@@ -136,6 +138,7 @@ class CosmosHistoryProvider(HistoryProvider):
**kwargs: Any,
) -> list[Message]:
"""Retrieve stored messages for this session from Azure Cosmos DB."""
mark_feature_used(FeatureIndex.AZURE_COSMOS)
await self._ensure_container_proxy()
session_key = self._session_partition_key(session_id)
@@ -176,6 +179,7 @@ class CosmosHistoryProvider(HistoryProvider):
**kwargs: Any,
) -> None:
"""Persist messages for this session to Azure Cosmos DB."""
mark_feature_used(FeatureIndex.AZURE_COSMOS)
if not messages:
return
@@ -17,6 +17,7 @@ from azure.cosmos.aio import CosmosClient
from azure.cosmos.exceptions import CosmosResourceNotFoundError
import agent_framework_azure_cosmos._history_provider as history_provider_module
from agent_framework_azure_cosmos._feature_usage import FeatureIndex
from agent_framework_azure_cosmos._history_provider import CosmosHistoryProvider
skip_if_cosmos_integration_tests_disabled = pytest.mark.skipif(
@@ -44,6 +45,15 @@ def _to_async_iter(items: list[Any]) -> AsyncIterator[Any]:
return _iterator()
async def test_save_messages_marks_azure_cosmos_used_before_empty_return() -> None:
provider = object.__new__(CosmosHistoryProvider)
with patch("agent_framework_azure_cosmos._history_provider.mark_feature_used") as mark_feature_used:
await provider.save_messages(None, [])
mark_feature_used.assert_called_once_with(FeatureIndex.AZURE_COSMOS)
@pytest.fixture
def mock_container() -> MagicMock:
container = MagicMock()
@@ -21,6 +21,7 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
import azure.durable_functions as df
import azure.functions as func
from agent_framework import SupportsAgentRun, Workflow
from agent_framework._telemetry import mark_feature_used
from agent_framework_durabletask import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
@@ -56,6 +57,7 @@ from agent_framework_durabletask._workflows.serialization import strip_pickle_ma
from ._entities import create_agent_entity
from ._errors import IncomingRequestError
from ._feature_usage import FeatureIndex
from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor
from ._routes import build_workflow_respond_url, build_workflow_status_url, split_request_url
from ._workflow import run_workflow_orchestrator
@@ -292,6 +294,7 @@ class AgentFunctionApp(DFAppBase):
if self.enable_health_check:
self._setup_health_route()
mark_feature_used(FeatureIndex.AZUREFUNCTIONS)
logger.debug("[AgentFunctionApp] Initialization complete")
def _collect_workflows(
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Azure Functions-owned feature-usage indexes."""
AZUREFUNCTIONS = 78
@@ -31,7 +31,7 @@ from agent_framework import (
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework.exceptions import ChatClientInvalidResponseException
from agent_framework.observability import ChatTelemetryLayer
from boto3.session import Session as Boto3Session
@@ -40,6 +40,8 @@ from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from pydantic import BaseModel
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
@@ -331,6 +333,7 @@ class BedrockChatClient(
return Boto3Session(**session_kwargs)
def _invoke_converse(self, request: Mapping[str, Any]) -> dict[str, Any]:
mark_feature_used(FeatureIndex.BEDROCK)
try:
response = self._bedrock_client.converse(**request)
if not isinstance(response, Mapping):
@@ -19,12 +19,14 @@ from agent_framework import (
UsageDetails,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework.observability import EmbeddingTelemetryLayer
from boto3.session import Session as Boto3Session
from botocore.client import BaseClient
from botocore.config import Config as BotoConfig
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
@@ -180,6 +182,7 @@ class RawBedrockEmbeddingClient(
if not model:
raise ValueError("model is required")
mark_feature_used(FeatureIndex.BEDROCK)
embedding_results = await asyncio.gather(
*(self._generate_embedding_for_text(opts, model, text) for text in values)
)
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Amazon Bedrock-owned feature-usage indexes."""
BEDROCK = 58
@@ -16,6 +16,7 @@ from botocore.client import BaseClient
from agent_framework_bedrock import BedrockChatClient
from agent_framework_bedrock._chat_client import BedrockSettings
from agent_framework_bedrock._feature_usage import FeatureIndex
class _StubBedrockRuntime:
@@ -67,8 +68,10 @@ async def test_get_response_invokes_bedrock_runtime() -> None:
Message(role="user", contents=[Content.from_text(text="hello")]),
]
response = await client.get_response(messages=messages, options={"max_tokens": 32})
with patch("agent_framework_bedrock._chat_client.mark_feature_used") as mark_feature_used:
response = await client.get_response(messages=messages, options={"max_tokens": 32})
mark_feature_used.assert_called_once_with(FeatureIndex.BEDROCK)
assert stub.calls, "Expected the runtime client to be called"
payload = stub.calls[0]
assert payload["modelId"] == "amazon.titan-text"
@@ -11,6 +11,7 @@ from agent_framework import (
Content,
Message,
)
from agent_framework._telemetry import mark_feature_used
from chatkit.types import (
AssistantMessageItem,
Attachment,
@@ -30,6 +31,8 @@ from chatkit.types import (
WorkflowItem,
)
from ._feature_usage import FeatureIndex
logger = logging.getLogger(__name__)
@@ -610,4 +613,5 @@ async def simple_to_agent_input(thread_items: Sequence[ThreadItem] | ThreadItem)
# Convert multiple items
messages = await simple_to_agent_input([user_message_item, assistant_message_item, task_item])
"""
mark_feature_used(FeatureIndex.CHATKIT)
return await _DEFAULT_CONVERTER.to_agent_input(thread_items)
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""ChatKit-owned feature-usage indexes."""
CHATKIT = 73
@@ -7,6 +7,7 @@ from collections.abc import AsyncIterable, AsyncIterator, Callable
from datetime import datetime
from agent_framework import AgentResponseUpdate
from agent_framework._telemetry import mark_feature_used
from chatkit.types import (
AssistantMessageContent,
AssistantMessageContentPartTextDelta,
@@ -17,6 +18,8 @@ from chatkit.types import (
ThreadStreamEvent,
)
from ._feature_usage import FeatureIndex
async def stream_agent_response(
response_stream: AsyncIterable[AgentResponseUpdate],
@@ -44,6 +47,7 @@ async def stream_agent_response(
ThreadStreamEvent: ChatKit events representing the agent's response,
including incremental text deltas for streaming display.
"""
mark_feature_used(FeatureIndex.CHATKIT)
# Use provided ID generator or create default one
if generate_id is None:
@@ -28,6 +28,7 @@ from agent_framework import (
normalize_messages,
normalize_tools,
)
from agent_framework._telemetry import mark_feature_used
from agent_framework.exceptions import AgentException
from agent_framework.observability import AgentTelemetryLayer
from claude_agent_sdk import (
@@ -42,6 +43,8 @@ from claude_agent_sdk import (
)
from claude_agent_sdk.types import StreamEvent, TextBlock
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
@@ -779,6 +782,7 @@ class RawClaudeAgent(BaseAgent, Generic[OptionsT]):
session_id: str | None = None
structured_output: Any = None
mark_feature_used(FeatureIndex.CLAUDE)
await self._client.query(prompt)
async for message in self._client.receive_response():
if isinstance(message, StreamEvent):
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Claude Agent SDK-owned feature-usage indexes."""
CLAUDE = 62
@@ -9,6 +9,7 @@ from agent_framework._settings import load_settings
from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings
from agent_framework_claude._agent import TOOLS_MCP_SERVER_NAME
from agent_framework_claude._feature_usage import FeatureIndex
# region Test ClaudeAgentSettings
@@ -231,9 +232,13 @@ class TestClaudeAgentRun:
]
mock_client = self._create_mock_client(messages)
with patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client):
with (
patch("agent_framework_claude._agent.ClaudeSDKClient", return_value=mock_client),
patch("agent_framework_claude._agent.mark_feature_used") as mark_feature_used,
):
agent = ClaudeAgent()
response = await agent.run("Hello")
mark_feature_used.assert_called_once_with(FeatureIndex.CLAUDE)
assert response.text == "Hello!"
async def test_run_captures_session_id(self) -> None:
@@ -18,11 +18,13 @@ from agent_framework import (
normalize_messages,
)
from agent_framework._settings import load_settings
from agent_framework._telemetry import mark_feature_used
from agent_framework._types import AgentRunInputs
from agent_framework.exceptions import AgentException
from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud
from ._acquire_token import acquire_token
from ._feature_usage import FeatureIndex
class CopilotStudioSettings(TypedDict, total=False):
@@ -255,6 +257,7 @@ class CopilotStudioAgent(BaseAgent):
question = "\n".join([message.text for message in input_messages])
mark_feature_used(FeatureIndex.COPILOTSTUDIO)
activities = self.client.ask_question(question, service_session_id)
response_messages: list[Message] = []
response_id: str | None = None
@@ -287,6 +290,7 @@ class CopilotStudioAgent(BaseAgent):
question = "\n".join([message.text for message in input_messages])
mark_feature_used(FeatureIndex.COPILOTSTUDIO)
activities = self.client.ask_question(question, service_session_id)
async for message in self._process_activities(activities, streaming=True):
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Copilot Studio-owned feature-usage indexes."""
COPILOTSTUDIO = 63
@@ -9,6 +9,7 @@ from agent_framework.exceptions import AgentException
from microsoft_agents.copilotstudio.client import CopilotClient
from agent_framework_copilotstudio import CopilotStudioAgent
from agent_framework_copilotstudio._feature_usage import FeatureIndex
def create_async_generator(items: list[Any]) -> Any:
@@ -136,8 +137,10 @@ class TestCopilotStudioAgent:
mock_copilot_client.start_conversation.return_value = create_async_generator([conversation_activity])
mock_copilot_client.ask_question.return_value = create_async_generator([mock_activity])
response = await agent.run("test message")
with patch("agent_framework_copilotstudio._agent.mark_feature_used") as mark_feature_used:
response = await agent.run("test message")
mark_feature_used.assert_called_once_with(FeatureIndex.COPILOTSTUDIO)
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
content = response.messages[0].contents[0]
+1
View File
@@ -17,6 +17,7 @@ agent_framework/
├── _sessions.py # AgentSession and context provider abstractions
├── _skills.py # Agent Skills system (models, executors, provider)
├── _mcp.py # Model Context Protocol support
├── _telemetry.py # User-Agent identity and internal feature-usage mask
├── _workflows/ # Workflow orchestration (sequential, concurrent, handoff, etc.)
├── openai/ # Built-in OpenAI client
├── azure/ # Lazy-loading entry point for Azure integrations
+11
View File
@@ -53,6 +53,17 @@ client = OpenAIChatClient(
)
```
### Telemetry controls
Agent Framework adds its package/version User-Agent to supported client
requests. Approved Microsoft Foundry and Azure OpenAI request paths can also
carry a documented feature-usage token.
- `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED=true` disables only the feature-usage
token while retaining the package/version User-Agent.
- `AGENT_FRAMEWORK_USER_AGENT_DISABLED=true` disables the entire Agent Framework
User-Agent contribution, including the feature token.
See the following [getting started samples](https://github.com/microsoft/agent-framework/tree/main/python/samples/01-get-started) for more information.
## 2. Create a Simple Agent
@@ -18,7 +18,7 @@ from collections.abc import Mapping
from typing import Any, Final
try:
_version = importlib.metadata.version(__name__)
_version = importlib.metadata.version("agent-framework-core")
except importlib.metadata.PackageNotFoundError:
_version = "0.0.0" # Fallback for development mode
__version__: Final[str] = _version
@@ -37,6 +37,7 @@ from ._sessions import (
SessionContext,
is_local_history_conversation_id,
)
from ._telemetry import FeatureIndex, mark_feature_used
from ._types import (
AgentResponse,
AgentResponseUpdate,
@@ -1773,6 +1774,7 @@ class Agent(
client_kwargs: Mapping[str, Any] | None = None,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
"""Run the agent."""
mark_feature_used(FeatureIndex.CORE_AGENT)
super_run = cast(
"Callable[..., Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]]",
super().run,
@@ -17,6 +17,7 @@ from typing import (
)
from ._sessions import ContextProvider
from ._telemetry import FeatureIndex, mark_feature_used
from ._types import ChatResponse, Content, Message
if TYPE_CHECKING:
@@ -1539,6 +1540,7 @@ class CompactionProvider(ContextProvider):
state: dict[str, Any],
) -> None:
"""Compact messages already present in the context from earlier providers."""
mark_feature_used(FeatureIndex.CORE_COMPACTION_PROVIDER)
if self.before_strategy is None:
return
@@ -22,6 +22,7 @@ from .._compaction import CompactionProvider, ContextWindowCompactionStrategy
from .._feature_stage import ExperimentalFeature, warn_experimental_feature
from .._sessions import ContextProvider, HistoryProvider, InMemoryHistoryProvider, MessageInjectionMiddleware
from .._skills import SkillsProvider
from .._telemetry import FeatureIndex, mark_feature_used
from .._types import ChatOptions
from ._background_agents import BackgroundAgentsProvider
from ._file_access import AgentFileStore, FileAccessProvider, FileSystemAgentFileStore
@@ -674,5 +675,6 @@ def create_harness_agent(
# Set the telemetry provider name after construction.
agent.otel_provider_name = otel_provider_name or HARNESS_AGENT_PROVIDER_NAME
mark_feature_used(FeatureIndex.CORE_HARNESS_AGENT)
return agent
@@ -19,6 +19,7 @@ from .._agents import SupportsAgentRun
from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import SerializationMixin
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._telemetry import FeatureIndex, mark_feature_used
from .._tools import tool
from .._types import AgentResponse, Message
@@ -320,6 +321,7 @@ class BackgroundAgentsProvider(ContextProvider):
state: dict[str, Any],
) -> None:
"""Inject background agent tools and instructions before the model runs."""
mark_feature_used(FeatureIndex.CORE_BACKGROUND_AGENTS_PROVIDER)
del agent, state
provider_state = _get_provider_state(session, source_id=self.source_id)
@@ -38,6 +38,7 @@ from pydantic import BaseModel, Field
from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import SerializationMixin
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._telemetry import FeatureIndex, mark_feature_used
from .._tools import ApprovalMode, tool
from .._types import Content
@@ -1454,6 +1455,7 @@ class FileAccessProvider(ContextProvider):
state: dict[str, Any],
) -> None:
"""Inject file-access tools and instructions before the model runs."""
mark_feature_used(FeatureIndex.CORE_FILE_ACCESS_PROVIDER)
readonly_approval: ApprovalMode = "never_require" if self.disable_readonly_tool_approval else "always_require"
write_approval: ApprovalMode = "never_require" if self.disable_write_tool_approval else "always_require"
@@ -20,6 +20,7 @@ from .._clients import SupportsChatGetResponse
from .._compaction import group_messages
from .._feature_stage import ExperimentalFeature, experimental
from .._sessions import AgentSession, FileHistoryProvider, HistoryProvider, JsonDumps, JsonLoads, SessionContext
from .._telemetry import FeatureIndex, mark_feature_used
from .._tools import tool
from .._types import ChatResponse, Message
from ..exceptions import ChatClientException
@@ -1170,6 +1171,7 @@ class MemoryContextProvider(HistoryProvider):
state: dict[str, Any],
) -> None:
"""Inject ``MEMORY.md`` and selected topic files before the model runs."""
mark_feature_used(FeatureIndex.CORE_MEMORY_PROVIDER)
state.clear()
state.update(self.store.export_provider_state(session))
@@ -7,6 +7,7 @@ from collections.abc import Mapping, Sequence
from typing import Any, cast
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._telemetry import FeatureIndex, mark_feature_used
from .._tools import tool
from .._types import Message
@@ -272,6 +273,7 @@ class AgentModeProvider(ContextProvider):
context: The session context to receive instructions and tools.
state: Per-provider invocation state.
"""
mark_feature_used(FeatureIndex.CORE_AGENT_MODE_PROVIDER)
del agent, state
current_mode = get_agent_mode(
session,
@@ -17,6 +17,7 @@ from typing_extensions import NotRequired, TypedDict
from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import SerializationMixin
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._telemetry import FeatureIndex, mark_feature_used
from .._tools import tool
from .._types import Message
@@ -498,6 +499,7 @@ class TodoProvider(ContextProvider):
state: dict[str, Any],
) -> None:
"""Inject todo tools and instructions before the model runs."""
mark_feature_used(FeatureIndex.CORE_TODO_PROVIDER)
del agent, state
@tool(name="todos_add", approval_mode="never_require")
@@ -12,6 +12,7 @@ from typing import Any, Literal, cast
from .._middleware import AgentContext, AgentMiddleware
from .._serialization import SerializationMixin
from .._sessions import AgentSession
from .._telemetry import FeatureIndex, mark_feature_used
from .._types import (
AgentResponse,
AgentResponseUpdate,
@@ -379,6 +380,7 @@ class ToolApprovalMiddleware(AgentMiddleware):
async def process(self, context: AgentContext, call_next: Callable[[], Awaitable[None]]) -> None:
"""Process one agent invocation."""
mark_feature_used(FeatureIndex.CORE_TOOL_APPROVAL)
if context.session is None:
raise RuntimeError("ToolApprovalMiddleware requires an AgentSession.")
@@ -29,6 +29,7 @@ from ._feature_stage import (
_warn_on_feature_use, # pyright: ignore[reportPrivateUsage]
experimental,
)
from ._telemetry import FeatureIndex, mark_feature_used
from ._tools import FunctionTool
from ._types import (
ChatOptions,
@@ -1268,6 +1269,7 @@ class MCPTool:
await self._run_on_lifecycle_owner("connect", reset=True, load_configured=False)
async def connect(self, *, reset: bool = False) -> None:
mark_feature_used(FeatureIndex.CORE_MCP)
if self._is_lifecycle_owner_task():
await self._connect_on_owner(reset=reset)
return
@@ -30,6 +30,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast
from ._feature_stage import ExperimentalFeature, experimental
from ._middleware import ChatContext, ChatMiddleware
from ._telemetry import FeatureIndex, mark_feature_used
from ._types import (
AgentResponse,
AgentRunInputs,
@@ -1168,6 +1169,7 @@ class InMemoryHistoryProvider(HistoryProvider):
self, session_id: str | None, *, state: dict[str, Any] | None = None, **kwargs: Any
) -> list[Message]:
"""Retrieve messages from session state."""
mark_feature_used(FeatureIndex.CORE_IN_MEMORY_HISTORY_PROVIDER)
if state is None:
return []
messages = list(state.get("messages", []))
@@ -1184,6 +1186,7 @@ class InMemoryHistoryProvider(HistoryProvider):
**kwargs: Any,
) -> None:
"""Persist messages to session state."""
mark_feature_used(FeatureIndex.CORE_IN_MEMORY_HISTORY_PROVIDER)
if state is None:
return
existing = state.get("messages", [])
@@ -1303,6 +1306,7 @@ class FileHistoryProvider(HistoryProvider):
**kwargs: Any,
) -> list[Message]:
"""Retrieve messages from the session's JSON Lines file."""
mark_feature_used(FeatureIndex.CORE_FILE_HISTORY_PROVIDER)
del state, kwargs
file_path = self._session_file_path(session_id)
async_lock = self._session_async_write_lock(file_path)
@@ -1354,6 +1358,7 @@ class FileHistoryProvider(HistoryProvider):
**kwargs: Any,
) -> None:
"""Append messages to the session's JSON Lines file."""
mark_feature_used(FeatureIndex.CORE_FILE_HISTORY_PROVIDER)
del state, kwargs
if not messages:
return
@@ -62,6 +62,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Final, Protocol, TypeAlias, Typ
from ._feature_stage import ExperimentalFeature, experimental
from ._sessions import ContextProvider
from ._telemetry import FeatureIndex, mark_feature_used
from ._tools import ApprovalMode, FunctionTool
if TYPE_CHECKING:
@@ -2407,6 +2408,7 @@ class SkillsProvider(ContextProvider):
context: Session context to extend with instructions and tools.
state: Mutable per-run state dictionary (unused by this provider).
"""
mark_feature_used(FeatureIndex.CORE_SKILLS_PROVIDER)
source_context = SkillsSourceContext(agent=agent, session=session)
skills, instructions, tools = await self._create_context(source_context)
@@ -2886,6 +2888,7 @@ class FileSkillsSource(SkillsSource):
Returns:
A list of discovered file-based skills.
"""
mark_feature_used(FeatureIndex.CORE_FILE_SKILLS_SOURCE)
skills: dict[str, FileSkill] = {}
discovered = FileSkillsSource._discover_skill_directories(self._skill_paths)
@@ -3591,6 +3594,7 @@ class InMemorySkillsSource(SkillsSource):
Returns:
A list of :class:`Skill` instances.
"""
mark_feature_used(FeatureIndex.CORE_IN_MEMORY_SKILLS_SOURCE)
return self._skills
@@ -4374,6 +4378,7 @@ class MCPSkillsSource(SkillsSource):
Returns:
A list of discovered :class:`MCPSkill` instances.
"""
mark_feature_used(FeatureIndex.CORE_MCP_SKILLS_SOURCE)
index = await self._try_read_index()
if index is None:
return []
@@ -5,6 +5,9 @@ from __future__ import annotations
import contextlib
import logging
import os
import re
import threading
from enum import IntEnum
from typing import Any, Final
from . import __version__ as version_info
@@ -15,6 +18,8 @@ logger = logging.getLogger("agent_framework")
# Note that if this environment variable does not exist, user agent telemetry is enabled.
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_USER_AGENT_DISABLED"
IS_TELEMETRY_ENABLED = os.environ.get(USER_AGENT_TELEMETRY_DISABLED_ENV_VAR, "false").lower() not in ["true", "1"]
FEATURE_MASK_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"
FEATURE_REGISTRY_VERSION = 1
APP_INFO = (
{
@@ -27,6 +32,29 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}"
class FeatureIndex(IntEnum):
"""Core-owned indexes in the Python feature-usage registry."""
CORE_AGENT = 0
CORE_HARNESS_AGENT = 1
CORE_WORKFLOW = 2
CORE_MCP = 3
CORE_TOOL_APPROVAL = 4
CORE_MEMORY_PROVIDER = 5
CORE_SKILLS_PROVIDER = 6
CORE_FILE_ACCESS_PROVIDER = 7
CORE_COMPACTION_PROVIDER = 8
CORE_TODO_PROVIDER = 9
CORE_AGENT_MODE_PROVIDER = 10
CORE_BACKGROUND_AGENTS_PROVIDER = 11
CORE_IN_MEMORY_HISTORY_PROVIDER = 12
CORE_FILE_HISTORY_PROVIDER = 13
CORE_FILE_SKILLS_SOURCE = 14
CORE_IN_MEMORY_SKILLS_SOURCE = 15
CORE_MCP_SKILLS_SOURCE = 16
# This environment variable is reserved by the Foundry hosting environment to
# indicate that the agent is running in a hosted environment.
_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT"
@@ -35,6 +63,9 @@ _HOSTED_USER_AGENT_PREFIX = "foundry-hosting"
_user_agent_prefixes: set[str] = set()
_hosted_env_detected: bool = False
_feature_mask = 0
_feature_mask_lock = threading.Lock()
_feature_comment_pattern = re.compile(r"(?:^|\s+)\(feat=v\d+\.[0-9a-fA-F]+\)")
def _add_user_agent_prefix(prefix: str) -> None:
@@ -96,6 +127,53 @@ def get_user_agent() -> str:
return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}"
def _feature_mask_enabled() -> bool:
"""Return whether feature-usage marking and emission are enabled."""
return IS_TELEMETRY_ENABLED and os.environ.get(FEATURE_MASK_DISABLED_ENV_VAR, "false").lower() not in ("true", "1")
def mark_feature_used(index: IntEnum | int) -> None:
"""Mark a feature as used in the process-global feature mask."""
if not _feature_mask_enabled():
return
feature_index = int(index)
if not 0 <= feature_index < 128:
raise ValueError(f"Feature index must be in range 0..127, got {feature_index}")
global _feature_mask
with _feature_mask_lock:
_feature_mask |= 1 << feature_index
def get_feature_token() -> str | None:
"""Return the current versioned feature token, or None when empty or disabled."""
if not _feature_mask_enabled():
return None
with _feature_mask_lock:
feature_mask = _feature_mask
if feature_mask == 0:
return None
return f"v{FEATURE_REGISTRY_VERSION}.{feature_mask:x}"
def apply_feature_token(user_agent: str) -> str:
"""Append or refresh the live feature token in a User-Agent value."""
base_user_agent = remove_feature_token(user_agent)
token = get_feature_token()
if token is None:
return base_user_agent
if not base_user_agent:
return f"(feat={token})"
return f"{base_user_agent} (feat={token})"
def remove_feature_token(user_agent: str) -> str:
"""Remove the Agent Framework feature token from a User-Agent value."""
return _feature_comment_pattern.sub("", user_agent).strip()
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
"""Prepend "agent-framework" to the User-Agent in the headers.
@@ -4,9 +4,10 @@ import logging
import sys
import uuid
from collections.abc import Callable, Sequence
from typing import Any, Literal
from typing import Any, ClassVar, Literal
from .._agents import SupportsAgentRun
from .._telemetry import FeatureIndex, mark_feature_used
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ._agent_executor import AgentExecutor
from ._agent_utils import resolve_agent_id
@@ -85,6 +86,8 @@ class WorkflowBuilder:
print(events.get_outputs()) # ['OLLEH']
"""
_FEATURE_USAGE_INDEX: ClassVar[FeatureIndex | None] = FeatureIndex.CORE_WORKFLOW
def __init__(
self,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
@@ -800,6 +803,8 @@ class WorkflowBuilder:
events = await workflow.run("hello")
print(events.get_outputs()) # outputs from planner and answerer
"""
if self._FEATURE_USAGE_INDEX is not None:
mark_feature_used(self._FEATURE_USAGE_INDEX)
# Create workflow build span that includes validation and workflow creation
with create_workflow_span(OtelAttr.WORKFLOW_BUILD_SPAN) as span:
try:
@@ -1,8 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
import ast
import concurrent.futures
import importlib.metadata
import os
import re
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import agent_framework._telemetry as _telemetry_mod
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
@@ -13,8 +20,15 @@ from agent_framework import (
from agent_framework._telemetry import (
_FOUNDRY_HOSTING_ENV_VAR,
_HOSTED_USER_AGENT_PREFIX,
FEATURE_MASK_DISABLED_ENV_VAR,
FEATURE_REGISTRY_VERSION,
FeatureIndex,
_add_user_agent_prefix,
_detect_hosted_environment,
apply_feature_token,
get_feature_token,
mark_feature_used,
remove_feature_token,
)
# region Test constants
@@ -35,6 +49,209 @@ def test_agent_framework_user_agent_format():
assert AGENT_FRAMEWORK_USER_AGENT.startswith("agent-framework-python/")
def test_agent_framework_user_agent_uses_core_distribution_version() -> None:
core_version = importlib.metadata.version("agent-framework-core")
assert f"agent-framework-python/{core_version}" == AGENT_FRAMEWORK_USER_AGENT
def _reset_feature_mask() -> None:
with _telemetry_mod._feature_mask_lock:
_telemetry_mod._feature_mask = 0
def test_feature_mask_disabled_env_var() -> None:
assert FEATURE_MASK_DISABLED_ENV_VAR == "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"
def test_feature_registry_version() -> None:
assert FEATURE_REGISTRY_VERSION == 1
def test_mark_feature_used_accumulates_and_deduplicates() -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
):
mark_feature_used(FeatureIndex.CORE_AGENT)
mark_feature_used(FeatureIndex.CORE_AGENT)
mark_feature_used(FeatureIndex.CORE_WORKFLOW)
assert get_feature_token() == "v1.5"
def test_mark_feature_used_supports_bit_127() -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
):
mark_feature_used(127)
assert get_feature_token() == f"v1.{1 << 127:x}"
@pytest.mark.parametrize("bit", [-1, 128])
def test_mark_feature_used_rejects_out_of_range_bit(bit: int) -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
pytest.raises(ValueError, match="Feature index must be in range 0..127"),
):
mark_feature_used(bit)
@pytest.mark.parametrize("disabled_value", ["true", "TRUE", "1"])
def test_feature_mask_env_var_disables_marking(disabled_value: str) -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: disabled_value}),
):
mark_feature_used(FeatureIndex.CORE_AGENT)
assert get_feature_token() is None
def test_user_agent_env_var_disables_feature_mask() -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", False),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
):
mark_feature_used(FeatureIndex.CORE_AGENT)
assert get_feature_token() is None
def test_apply_feature_token_adds_and_refreshes_live_mask() -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
):
mark_feature_used(FeatureIndex.CORE_AGENT)
user_agent = apply_feature_token("foundry-hosting/agent-framework-python/1.0")
assert user_agent == "foundry-hosting/agent-framework-python/1.0 (feat=v1.1)"
mark_feature_used(FeatureIndex.CORE_WORKFLOW)
assert apply_feature_token(user_agent) == "foundry-hosting/agent-framework-python/1.0 (feat=v1.5)"
def test_apply_feature_token_preserves_unrelated_comments() -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
):
mark_feature_used(FeatureIndex.CORE_AGENT)
assert apply_feature_token("agent-framework-python/1.0 (custom=value)") == (
"agent-framework-python/1.0 (custom=value) (feat=v1.1)"
)
def test_remove_feature_token_strips_standalone_token() -> None:
assert remove_feature_token("(feat=v1.1)") == ""
def test_apply_feature_token_removes_stale_token_when_disabled() -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "true"}),
):
assert apply_feature_token("agent-framework-python/1.0 (feat=v1.5)") == "agent-framework-python/1.0"
assert apply_feature_token("(feat=v1.5)") == ""
def test_mark_feature_used_is_thread_safe() -> None:
_reset_feature_mask()
with (
patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True),
patch.dict(os.environ, {FEATURE_MASK_DISABLED_ENV_VAR: "false"}),
concurrent.futures.ThreadPoolExecutor() as executor,
):
list(executor.map(mark_feature_used, range(128)))
assert get_feature_token() == f"v1.{(1 << 128) - 1:x}"
def test_declared_feature_indexes_match_registry() -> None:
registry_path = next(
(
parent / "docs" / "specs" / "feature-usage-bit-registry.md"
for parent in Path(__file__).resolve().parents
if (parent / "docs" / "specs" / "feature-usage-bit-registry.md").exists()
),
None,
)
if registry_path is None:
pytest.skip("Feature-usage registry is not available outside a repository checkout.")
repository_root = registry_path.parents[2]
registry_text = registry_path.read_text(encoding="utf-8")
python_table = registry_text.split("## Index table — Python", 1)[1].split("## Index table — .NET", 1)[0]
registry_rows = re.findall(r"^\| (\d+) \| `([^`]+)` \|", python_table, re.MULTILINE)
registry_pairs = {(int(index), identifier.upper().replace(".", "_")) for index, identifier in registry_rows}
assert len(registry_pairs) == len(registry_rows), "Python v1 registry contains duplicate (index, id) rows."
assert all(0 <= index < 128 for index, _ in registry_pairs)
declaration_files = [
repository_root / "python" / "packages" / "core" / "agent_framework" / "_telemetry.py",
*repository_root.glob("python/packages/**/_feature_usage.py"),
]
declarations_by_index: dict[int, str] = {}
declaration_pairs: set[tuple[int, str]] = set()
declaration_owners: dict[tuple[int, str], tuple[Path, Path]] = {}
for declaration_file in declaration_files:
tree = ast.parse(declaration_file.read_text(encoding="utf-8"))
for node in tree.body:
if not isinstance(node, ast.ClassDef) or node.name != "FeatureIndex":
continue
for member in node.body:
if not isinstance(member, ast.Assign) or len(member.targets) != 1:
continue
target = member.targets[0]
if not isinstance(target, ast.Name) or not isinstance(member.value, ast.Constant):
continue
index = member.value.value
if not isinstance(index, int):
continue
declaration = f"{declaration_file.relative_to(repository_root)}:{target.id}"
assert 0 <= index < 128, f"Feature index {index} is out of range in {declaration}."
assert index not in declarations_by_index, (
f"Feature index {index} overlaps between {declarations_by_index[index]} and {declaration}."
)
declarations_by_index[index] = declaration
pair = (index, target.id)
declaration_pairs.add(pair)
package_root = repository_root.joinpath(*declaration_file.relative_to(repository_root).parts[:3])
declaration_owners[pair] = (package_root, declaration_file)
assert declaration_pairs == registry_pairs
for pair, (package_root, declaration_file) in declaration_owners.items():
_, member_name = pair
referenced = False
for source_file in package_root.rglob("*.py"):
if source_file == declaration_file or "tests" in source_file.parts:
continue
source_tree = ast.parse(source_file.read_text(encoding="utf-8"))
if any(
isinstance(node, ast.Attribute)
and isinstance(node.value, ast.Name)
and node.value.id == "FeatureIndex"
and node.attr == member_name
for node in ast.walk(source_tree)
):
referenced = True
break
assert referenced, f"Feature index {pair} is declared but never referenced by its owning package."
def test_app_info_when_telemetry_enabled():
"""Test that APP_INFO is set when telemetry is enabled."""
with patch("agent_framework._telemetry.IS_TELEMETRY_ENABLED", True):
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Declarative-owned feature-usage indexes."""
DECLARATIVE_AGENT = 75
DECLARATIVE_WORKFLOW = 76
@@ -19,9 +19,11 @@ from agent_framework._feature_stage import (
ExperimentalFeature,
experimental,
)
from agent_framework._telemetry import mark_feature_used
from agent_framework.exceptions import AgentException
from dotenv import load_dotenv
from ._feature_usage import FeatureIndex
from ._models import (
AnonymousConnection,
ApiKeyConnection,
@@ -471,13 +473,15 @@ class AgentFactory:
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = output_schema.to_json_schema()
# Step 3: Create the agent instance
return Agent(
agent = Agent(
client=client,
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
default_options=chat_options, # type: ignore[arg-type]
)
mark_feature_used(FeatureIndex.DECLARATIVE_AGENT)
return agent
async def create_agent_from_yaml_path_async(self, yaml_path: str | Path) -> Agent:
"""Async version: Create a Agent from a YAML file path.
@@ -582,13 +586,15 @@ class AgentFactory:
chat_options["tools"] = tools
if output_schema := prompt_agent.outputSchema:
chat_options["response_format"] = output_schema.to_json_schema()
return Agent(
agent = Agent(
client=client,
name=prompt_agent.name,
description=prompt_agent.description,
instructions=prompt_agent.instructions,
default_options=chat_options, # type: ignore[arg-type]
)
mark_feature_used(FeatureIndex.DECLARATIVE_AGENT)
return agent
async def _create_agent_with_provider(self, prompt_agent: PromptAgent, mapping: ProviderTypeMapping) -> Agent:
"""Create an Agent through a provider object that exposes ``create_agent``.
@@ -24,7 +24,9 @@ from agent_framework import (
SupportsAgentRun,
Workflow,
)
from agent_framework._telemetry import mark_feature_used
from .._feature_usage import FeatureIndex
from .._loader import AgentFactory
from ._declarative_base import DeclarativeEnvConfig, discover_env_references
from ._declarative_builder import DeclarativeWorkflowBuilder
@@ -471,6 +473,7 @@ class WorkflowFactory:
len(graph_builder._executors), # type: ignore[reportPrivateUsage]
)
mark_feature_used(FeatureIndex.DECLARATIVE_WORKFLOW)
return workflow
def _normalize_workflow_def(self, workflow_def: dict[str, Any]) -> dict[str, Any]:
@@ -492,6 +492,38 @@ class TestAgentFactoryCreateFromDict:
assert agent is not None
def test_create_agent_from_dict_marks_declarative_agent_used(self):
"""Test that successful declarative agent creation marks feature usage."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._feature_usage import FeatureIndex
factory = AgentFactory(client=MagicMock())
with patch("agent_framework_declarative._loader.mark_feature_used") as mark_feature_used:
factory.create_agent_from_dict({
"kind": "Prompt",
"name": "TestAgent",
"instructions": "You are a helpful assistant.",
})
mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_AGENT)
async def test_create_agent_from_dict_async_marks_declarative_agent_used(self):
"""Test that successful async declarative agent creation marks feature usage."""
from agent_framework_declarative import AgentFactory
from agent_framework_declarative._feature_usage import FeatureIndex
factory = AgentFactory(client=MagicMock())
with patch("agent_framework_declarative._loader.mark_feature_used") as mark_feature_used:
await factory.create_agent_from_dict_async({
"kind": "Prompt",
"name": "TestAgent",
"instructions": "You are a helpful assistant.",
})
mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_AGENT)
def test_create_agent_from_dict_matches_yaml(self):
"""Test that create_agent_from_dict produces same result as create_agent_from_yaml."""
from unittest.mock import MagicMock
@@ -3,9 +3,11 @@
"""Unit tests for WorkflowFactory."""
from typing import Any, cast
from unittest.mock import patch
import pytest
from agent_framework_declarative._feature_usage import FeatureIndex
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
from agent_framework_declarative._workflows._factory import WorkflowFactory
@@ -66,6 +68,24 @@ actions:
assert workflow is not None
assert workflow.name == "minimal-workflow"
def test_valid_workflow_marks_declarative_workflow_used(self):
"""Test that successful declarative workflow creation marks feature usage."""
factory = WorkflowFactory()
with patch("agent_framework_declarative._workflows._factory.mark_feature_used") as mark_feature_used:
factory.create_workflow_from_definition({
"name": "minimal-workflow",
"actions": [
{
"kind": "SetValue",
"path": "Local.result",
"value": "done",
}
],
})
mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_WORKFLOW)
@_requires_powerfx
class TestWorkflowFactoryExecution:
@@ -8,7 +8,10 @@ import webbrowser
from collections.abc import Callable
from typing import Any
from agent_framework._telemetry import mark_feature_used
from ._conversations import CheckpointConversationManager
from ._feature_usage import FeatureIndex
from ._server import DevServer
from .models import AgentFrameworkRequest, OpenAIError, OpenAIResponse, ResponseStreamEvent
from .models._discovery_models import DiscoveryResponse, EntityInfo, EnvVarRequirement
@@ -196,6 +199,7 @@ def serve(
threading.Thread(target=open_browser, daemon=True).start()
logger.info(f"Starting Agent Framework DevUI on {host}:{port}")
mark_feature_used(FeatureIndex.DEVUI)
uvicorn.run(app, host=host, port=port, log_level="info")
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""DevUI-owned feature-usage indexes."""
DEVUI = 74
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Durable Task-owned feature-usage indexes."""
DURABLETASK = 77
@@ -13,9 +13,11 @@ from abc import ABC, abstractmethod
from typing import Any, Generic, Literal, TypeVar
from agent_framework import AgentSession, ServiceSessionId, SupportsAgentRun, normalize_messages
from agent_framework._telemetry import mark_feature_used
from agent_framework._types import AgentRunInputs
from ._executors import DurableAgentExecutor
from ._feature_usage import FeatureIndex
from ._models import DurableAgentSession
# TypeVar for the task type returned by executors
@@ -127,6 +129,7 @@ class DurableAIAgent(SupportsAgentRun, Generic[TaskT]):
options=options,
)
mark_feature_used(FeatureIndex.DURABLETASK)
return self._executor.run_durable_agent(
agent_name=self.name,
run_request=run_request,
@@ -13,12 +13,14 @@ import logging
from typing import Any
from agent_framework import SupportsAgentRun, Workflow
from agent_framework._telemetry import mark_feature_used
from durabletask.task import ActivityContext, OrchestrationContext
from durabletask.worker import TaskHubGrpcWorker
from ._async_bridge import run_agent_coroutine
from ._callbacks import AgentResponseCallbackProtocol
from ._entities import AgentEntity, DurableTaskEntityStateProvider
from ._feature_usage import FeatureIndex
from ._workflows.activity import execute_workflow_activity
from ._workflows.dt_context import DurableTaskWorkflowContext
from ._workflows.naming import (
@@ -157,6 +159,7 @@ class DurableAIAgentWorker:
The worker will block until stopped.
"""
logger.info("[DurableAIAgentWorker] Starting worker with %d registered agents", len(self._registered_agents))
mark_feature_used(FeatureIndex.DURABLETASK)
self._worker.start()
def stop(self) -> None:
@@ -29,7 +29,7 @@ from agent_framework import (
load_settings,
)
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent
from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
@@ -38,6 +38,11 @@ from azure.core.credentials_async import AsyncTokenCredential
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
from ._feature_usage import (
FeatureIndex,
create_feature_usage_policy,
create_foundry_feature_usage_http_client,
)
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
if sys.version_info >= (3, 13):
@@ -174,6 +179,7 @@ class RawFoundryAgentChatClient(
"""
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry"
_FEATURE_USAGE_INDEX: ClassVar[int | None] = FeatureIndex.FOUNDRY_AGENT
def __init__(
self,
@@ -251,8 +257,10 @@ class RawFoundryAgentChatClient(
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential,
"user_agent": get_user_agent(),
"per_retry_policies": [create_feature_usage_policy()],
}
if IS_TELEMETRY_ENABLED:
project_client_kwargs["user_agent"] = get_user_agent()
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
self.project_client = AIProjectClient(**project_client_kwargs)
@@ -261,6 +269,8 @@ class RawFoundryAgentChatClient(
openai_client_kwargs: dict[str, Any] = {}
if default_headers:
openai_client_kwargs["default_headers"] = dict(default_headers)
if self._should_close_client:
openai_client_kwargs["http_client"] = create_foundry_feature_usage_http_client()
if allow_preview:
openai_client_kwargs["agent_name"] = self.agent_name
openai_client = self.project_client.get_openai_client(**openai_client_kwargs)
@@ -799,7 +809,7 @@ class RawFoundryAgent(
Foundry conversation ID.
"""
client = cast(RawFoundryAgentChatClient, self.client)
conversation = await client.project_client.get_openai_client().conversations.create()
conversation = await client.client.conversations.create()
return self.get_session(service_session_id=conversation.id, session_id=session_id)
@override
@@ -17,7 +17,7 @@ from agent_framework import (
)
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._feature_stage import ExperimentalFeature, experimental
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
@@ -56,6 +56,11 @@ from azure.core.credentials_async import AsyncTokenCredential
from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
from ._feature_usage import (
FeatureIndex,
create_feature_usage_policy,
create_foundry_feature_usage_http_client,
)
from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
if sys.version_info >= (3, 13):
@@ -151,6 +156,7 @@ class RawFoundryChatClient(
OTEL_PROVIDER_NAME: ClassVar[str] = "azure.ai.foundry"
SUPPORTS_RICH_FUNCTION_OUTPUT: ClassVar[bool] = False
_FEATURE_USAGE_INDEX: ClassVar[int | None] = FeatureIndex.FOUNDRY_CHAT_CLIENT
def __init__(
self,
@@ -203,12 +209,13 @@ class RawFoundryChatClient(
project_endpoint = foundry_settings.get("project_endpoint")
owns_project_client = project_client is None
if project_endpoint is None and project_client is None:
raise ValueError(
"Either 'project_endpoint' or 'project_client' is required. "
"Set project_endpoint via parameter or 'FOUNDRY_PROJECT_ENDPOINT' environment variable."
)
if not project_client:
if project_client is None:
if not project_endpoint:
raise ValueError(
"Azure AI project endpoint is required. Set via 'project_endpoint' parameter "
@@ -220,8 +227,10 @@ class RawFoundryChatClient(
project_client_kwargs: dict[str, Any] = {
"endpoint": project_endpoint,
"credential": credential,
"user_agent": get_user_agent(),
"per_retry_policies": [create_feature_usage_policy()],
}
if IS_TELEMETRY_ENABLED:
project_client_kwargs["user_agent"] = get_user_agent()
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
@@ -229,6 +238,8 @@ class RawFoundryChatClient(
openai_kwargs: dict[str, Any] = {}
if default_headers:
openai_kwargs["default_headers"] = default_headers
if owns_project_client:
openai_kwargs["http_client"] = create_foundry_feature_usage_http_client()
super().__init__(
model=resolved_model,
@@ -17,11 +17,14 @@ from agent_framework import (
UsageDetails,
load_settings,
)
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent, mark_feature_used
from agent_framework.observability import EmbeddingTelemetryLayer
from azure.ai.inference.aio import EmbeddingsClient, ImageEmbeddingsClient
from azure.ai.inference.models import ImageEmbeddingInput
from azure.core.credentials import AzureKeyCredential
from ._feature_usage import FeatureIndex, create_feature_usage_policy
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
@@ -151,13 +154,19 @@ class RawFoundryEmbeddingClient(
if credential is None and text_client is None and image_client is None:
raise ValueError("Either 'api_key', 'credential', or pre-configured client(s) must be provided.")
client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential,
}
if IS_TELEMETRY_ENABLED:
client_kwargs["user_agent"] = get_user_agent()
self._text_client = text_client or EmbeddingsClient(
endpoint=resolved_endpoint, # type: ignore[arg-type]
credential=credential, # type: ignore[arg-type]
**client_kwargs,
per_retry_policies=[create_feature_usage_policy()],
)
self._image_client = image_client or ImageEmbeddingsClient(
endpoint=resolved_endpoint, # type: ignore[arg-type]
credential=credential, # type: ignore[arg-type]
**client_kwargs,
per_retry_policies=[create_feature_usage_policy()],
)
self._endpoint = resolved_endpoint
super().__init__(additional_properties=additional_properties)
@@ -206,6 +215,7 @@ class RawFoundryEmbeddingClient(
"""
if not values:
return GeneratedEmbeddings([], options=options)
mark_feature_used(FeatureIndex.FOUNDRY_EMBEDDING)
opts: dict[str, Any] = dict(options) if options else {}
@@ -0,0 +1,58 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
from typing import Any
from agent_framework._telemetry import (
USER_AGENT_KEY,
apply_feature_token,
remove_feature_token,
)
from agent_framework_openai._feature_usage import (
_is_approved_origin, # pyright: ignore[reportPrivateUsage]
create_feature_usage_http_client,
)
from azure.core.pipeline.policies import SansIOHTTPPolicy
from openai import DefaultAsyncHttpxClient
class FeatureIndex(IntEnum):
"""Foundry-owned feature-usage indexes."""
FOUNDRY_CHAT_CLIENT = 48
FOUNDRY_AGENT = 49
FOUNDRY_MEMORY = 50
FOUNDRY_EMBEDDING = 51
FOUNDRY_EVALS = 52
_FOUNDRY_ORIGIN_SUFFIXES = (
"inference.ai.azure.com",
"services.ai.azure.com",
)
def create_foundry_feature_usage_http_client() -> DefaultAsyncHttpxClient:
"""Create an OpenAI SDK client for approved Foundry origins."""
return create_feature_usage_http_client(approved_origin_suffixes=_FOUNDRY_ORIGIN_SUFFIXES)
def create_feature_usage_policy() -> "FeatureUsagePolicy":
"""Create the destination-aware policy that stamps each actual request hop."""
return FeatureUsagePolicy()
class FeatureUsagePolicy(SansIOHTTPPolicy[Any, Any]):
"""Refresh or remove the feature token based on the actual Azure request origin."""
def on_request(self, request: Any) -> None:
"""Apply destination-aware feature stamping to the current request hop."""
headers = request.http_request.headers
user_agent = headers.get(USER_AGENT_KEY)
if not isinstance(user_agent, str):
return
headers[USER_AGENT_KEY] = (
apply_feature_token(user_agent)
if _is_approved_origin(request.http_request.url, _FOUNDRY_ORIGIN_SUFFIXES)
else remove_feature_token(user_agent)
)
@@ -43,9 +43,11 @@ from agent_framework._evaluation import (
RubricScore,
)
from agent_framework._feature_stage import ExperimentalFeature, experimental
from agent_framework._telemetry import mark_feature_used
from openai import AsyncOpenAI
from ._chat_client import FoundryChatClient
from ._feature_usage import FeatureIndex
if TYPE_CHECKING:
from azure.ai.projects.aio import AIProjectClient
@@ -716,7 +718,14 @@ async def _evaluate_via_responses_impl(
data_source=data_source, # type: ignore[arg-type]
)
return await _poll_eval_run(client, eval_obj.id, run.id, poll_interval, timeout, provider=provider)
return await _poll_eval_run(
client,
eval_obj.id,
run.id,
poll_interval,
timeout,
provider=provider,
)
# ---------------------------------------------------------------------------
@@ -879,6 +888,7 @@ class FoundryEvals:
Returns:
``EvalResults`` with status, counts, and portal link.
"""
mark_feature_used(FeatureIndex.FOUNDRY_EVALS)
# Resolve evaluators with auto-detection
resolved = _resolve_default_evaluators(self._evaluators, items=items)
# Filter tool evaluators if items don't have tools
@@ -1023,6 +1033,7 @@ async def evaluate_traces(
)
"""
oai_client = _resolve_openai_client(client, project_client)
mark_feature_used(FeatureIndex.FOUNDRY_EVALS)
resolved_evaluators = _resolve_default_evaluators(evaluators)
if response_ids:
@@ -1060,7 +1071,13 @@ async def evaluate_traces(
data_source=trace_source, # type: ignore[arg-type]
)
return await _poll_eval_run(oai_client, eval_obj.id, run.id, poll_interval, timeout)
return await _poll_eval_run(
oai_client,
eval_obj.id,
run.id,
poll_interval,
timeout,
)
@experimental(feature_id=ExperimentalFeature.EVALS)
@@ -1109,6 +1126,7 @@ async def evaluate_foundry_target(
if "type" not in target:
raise ValueError("target dict must include a 'type' key (e.g., 'azure_ai_agent').")
oai_client = _resolve_openai_client(client, project_client)
mark_feature_used(FeatureIndex.FOUNDRY_EVALS)
resolved_evaluators = _resolve_default_evaluators(evaluators)
eval_obj = await oai_client.evals.create(
@@ -1135,4 +1153,10 @@ async def evaluate_foundry_target(
data_source=data_source, # type: ignore[arg-type]
)
return await _poll_eval_run(oai_client, eval_obj.id, run.id, poll_interval, timeout)
return await _poll_eval_run(
oai_client,
eval_obj.id,
run.id,
poll_interval,
timeout,
)
@@ -20,12 +20,14 @@ from agent_framework import (
SessionContext,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import IS_TELEMETRY_ENABLED, get_user_agent, mark_feature_used
from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from openai.types.responses import ResponseInputItemParam
from ._feature_usage import FeatureIndex, create_feature_usage_policy
if sys.version_info >= (3, 11):
from typing import Self, TypedDict # pragma: no cover
else:
@@ -119,8 +121,10 @@ class FoundryMemoryProvider(ContextProvider):
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential,
"user_agent": get_user_agent(),
"per_retry_policies": [create_feature_usage_policy()],
}
if IS_TELEMETRY_ENABLED:
project_client_kwargs["user_agent"] = get_user_agent()
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
project_client = AIProjectClient(**project_client_kwargs)
@@ -164,6 +168,7 @@ class FoundryMemoryProvider(ContextProvider):
2. Searches for contextual memories based on input messages
3. Combines and injects memories into the context
"""
mark_feature_used(FeatureIndex.FOUNDRY_MEMORY)
# On first run, retrieve static memories (user profile memories)
if not state.get("initialized"):
try:
@@ -9,7 +9,7 @@ import sys
from collections.abc import Awaitable, Callable
from types import SimpleNamespace
from typing import Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
@@ -31,6 +31,7 @@ from agent_framework import (
tool,
)
from agent_framework_openai._chat_client import RawOpenAIChatClient
from agent_framework_openai._feature_usage import FeatureIndex as OpenAIFeatureIndex
from azure.ai.projects import models as projects_models
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential
@@ -44,6 +45,7 @@ from agent_framework_foundry._agent import (
_FoundryAgentChatClient,
)
from agent_framework_foundry._chat_client import FoundryChatClient
from agent_framework_foundry._feature_usage import FeatureIndex, FeatureUsagePolicy
skip_if_foundry_agent_integration_tests_disabled = pytest.mark.skipif(
os.getenv("FOUNDRY_PROJECT_ENDPOINT", "") in ("", "https://test-project.services.ai.azure.com/")
@@ -60,6 +62,11 @@ _FOUNDRY_AZURE_AI_SEARCH_MODEL_ENV_VARS = (
)
def test_raw_foundry_agent_chat_client_does_not_mark_openai_feature() -> None:
assert RawOpenAIChatClient._FEATURE_USAGE_INDEX is OpenAIFeatureIndex.OPENAI
assert RawFoundryAgentChatClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_AGENT
def _get_foundry_azure_ai_search_model() -> str | None:
"""Return the model/deployment to use for local Azure AI Search integration validation."""
return next((os.environ[key] for key in _FOUNDRY_AZURE_AI_SEARCH_MODEL_ENV_VARS if os.getenv(key)), None)
@@ -119,6 +126,24 @@ def test_raw_foundry_agent_chat_client_init_with_agent_name() -> None:
mock_project.get_openai_client.assert_called_once_with()
def test_raw_foundry_agent_chat_client_creates_project_client_with_feature_policy() -> None:
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
with patch("agent_framework_foundry._agent.AIProjectClient", return_value=mock_project) as factory:
RawFoundryAgentChatClient(
project_endpoint="https://test-project.services.ai.azure.com",
credential=MagicMock(),
agent_name="test-agent",
)
policies = factory.call_args.kwargs["per_retry_policies"]
assert len(policies) == 1
assert isinstance(policies[0], FeatureUsagePolicy)
assert "custom_hook_policy" not in factory.call_args.kwargs
mock_project.get_openai_client.assert_called_once_with(http_client=ANY)
async def test_foundry_agent_basic_call_does_not_request_unsupported_encrypted_reasoning() -> None:
"""A Foundry agent call must not opt into encrypted reasoning unless the caller requests it."""
mock_response = MagicMock()
@@ -1129,12 +1154,13 @@ async def test_foundry_agent_create_conversation_returns_agent_session() -> None
mock_project = MagicMock()
mock_project.get_openai_client.return_value = openai_client
agent = FoundryAgent(project_client=mock_project, agent_name="test-agent")
mock_project.get_openai_client.reset_mock()
session = await agent.create_conversation()
assert isinstance(session, AgentSession)
assert session.service_session_id == "conv_123"
mock_project.get_openai_client.assert_called()
mock_project.get_openai_client.assert_not_called()
openai_client.conversations.create.assert_awaited_once_with()
@@ -1146,11 +1172,13 @@ async def test_foundry_agent_create_conversation_accepts_local_session_id() -> N
mock_project = MagicMock()
mock_project.get_openai_client.return_value = openai_client
agent = FoundryAgent(project_client=mock_project, agent_name="test-agent")
mock_project.get_openai_client.reset_mock()
session = await agent.create_conversation(session_id="local-session")
assert session.session_id == "local-session"
assert session.service_session_id == "conv_123"
mock_project.get_openai_client.assert_not_called()
def test_foundry_agent_init() -> None:
@@ -9,22 +9,27 @@ import warnings
from functools import wraps
from pathlib import Path
from typing import Annotated, Any, cast
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import agent_framework._telemetry as telemetry
import pytest
from agent_framework import Agent, ChatResponse, Content, Message, SupportsChatGetResponse, tool
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
from agent_framework_openai import OpenAIContentFilterException
from agent_framework_openai._chat_client import RawOpenAIChatClient
from azure.ai.projects.models import MCPTool as FoundryMCPTool
from azure.core.exceptions import ResourceNotFoundError
from azure.core.pipeline import Pipeline
from azure.core.pipeline.policies import RedirectPolicy, UserAgentPolicy
from azure.core.pipeline.transport import HttpRequest, HttpResponse, HttpTransport
from azure.identity import AzureCliCredential
from openai import BadRequestError
from pydantic import BaseModel
from pytest import param
from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient
from agent_framework_foundry._feature_usage import FeatureIndex, FeatureUsagePolicy
class OutputStruct(BaseModel):
@@ -34,6 +39,74 @@ class OutputStruct(BaseModel):
weather: str | None = None
def test_foundry_feature_usage_policy_refreshes_user_agent() -> None:
with telemetry._feature_mask_lock:
telemetry._feature_mask = 0
mark_feature_used(FeatureIndex.FOUNDRY_CHAT_CLIENT)
request = MagicMock()
request.http_request.url = "https://project.services.ai.azure.com/api/projects/test"
request.http_request.headers = {"User-Agent": "azsdk-python-ai-projects/1.0 agent-framework-python/1.0"}
FeatureUsagePolicy().on_request(request)
assert request.http_request.headers["User-Agent"] == (
"azsdk-python-ai-projects/1.0 agent-framework-python/1.0 (feat=v1.1000000000000)"
)
def test_foundry_feature_usage_policy_removes_token_on_cross_origin_redirect() -> None:
class _Response(HttpResponse):
def body(self) -> bytes:
return b""
class _RedirectTransport(HttpTransport[HttpRequest, HttpResponse]):
def __init__(self) -> None:
self.sent_headers: list[dict[str, str]] = []
def __enter__(self) -> _RedirectTransport:
return self
def __exit__(self, *args: Any) -> None:
self.close()
def open(self) -> None:
pass
def close(self) -> None:
pass
def send(self, request: HttpRequest, **kwargs: Any) -> HttpResponse:
self.sent_headers.append(dict(request.headers))
response = _Response(request, None)
if len(self.sent_headers) == 1:
response.status_code = 302
response.headers = {"location": "https://example.com/redirected"}
else:
response.status_code = 200
response.headers = {}
return response
with telemetry._feature_mask_lock:
telemetry._feature_mask = 0
mark_feature_used(FeatureIndex.FOUNDRY_CHAT_CLIENT)
transport = _RedirectTransport()
pipeline = cast(Any, Pipeline)(
transport, [UserAgentPolicy(user_agent=get_user_agent()), RedirectPolicy(), FeatureUsagePolicy()]
)
pipeline.run(HttpRequest("GET", "https://project.services.ai.azure.com/api/projects/test"))
assert "(feat=v1." in transport.sent_headers[0]["User-Agent"]
assert "(feat=v1." not in transport.sent_headers[1]["User-Agent"]
def test_foundry_feature_index_does_not_own_toolbox() -> None:
assert not hasattr(FeatureIndex, "FOUNDRY_TOOLBOX")
def test_raw_foundry_chat_client_owns_foundry_feature_bit() -> None:
assert RawFoundryChatClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_CHAT_CLIENT
@tool(approval_mode="never_require")
async def get_weather(location: Annotated[str, "The location as a city name"]) -> str:
"""Get the current weather in a given location."""
@@ -157,6 +230,7 @@ def test_init() -> None:
assert client.model == _TEST_FOUNDRY_MODEL
assert client.project_client is mock_project_client
assert isinstance(client, SupportsChatGetResponse)
mock_project_client.get_openai_client.assert_called_once_with()
def test_raw_foundry_chat_client_init_uses_explicit_parameters() -> None:
@@ -196,6 +270,7 @@ def test_init_with_default_header() -> None:
assert client.default_headers is not None
assert key in client.default_headers
assert client.default_headers[key] == value
project_client.get_openai_client.assert_called_once_with(default_headers=default_headers)
def test_init_with_project_endpoint_creates_project_client() -> None:
@@ -218,6 +293,11 @@ def test_init_with_project_endpoint_creates_project_client() -> None:
assert factory.call_args.kwargs["credential"] is credential
assert factory.call_args.kwargs["allow_preview"] is True
assert factory.call_args.kwargs["user_agent"] == get_user_agent()
policies = factory.call_args.kwargs["per_retry_policies"]
assert len(policies) == 1
assert isinstance(policies[0], FeatureUsagePolicy)
assert "custom_hook_policy" not in factory.call_args.kwargs
project_client.get_openai_client.assert_called_once_with(http_client=ANY)
def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -5,10 +5,11 @@ from __future__ import annotations
import os
from collections.abc import Sequence
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
from agent_framework import Content
from agent_framework._telemetry import get_user_agent
from agent_framework_foundry import (
FoundryEmbeddingClient,
@@ -200,12 +201,24 @@ class TestRawFoundryEmbeddingClient:
},
clear=True,
),
patch("agent_framework_foundry._embedding_client.EmbeddingsClient"),
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient"),
patch("agent_framework_foundry._embedding_client.EmbeddingsClient") as text_client_type,
patch("agent_framework_foundry._embedding_client.ImageEmbeddingsClient") as image_client_type,
):
client = RawFoundryEmbeddingClient()
assert client.model == "env-model"
assert client.image_model == "env-model" # falls back to model
text_client_type.assert_called_once_with(
endpoint="https://env.inference.ai.azure.com",
credential=ANY,
user_agent=get_user_agent(),
per_retry_policies=[ANY],
)
image_client_type.assert_called_once_with(
endpoint="https://env.inference.ai.azure.com",
credential=ANY,
user_agent=get_user_agent(),
per_retry_policies=[ANY],
)
def test_image_model_from_env(self) -> None:
"""image_model is loaded from its own environment variable."""
@@ -5,7 +5,7 @@ from __future__ import annotations
import os
from typing import Any, cast
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import ANY, AsyncMock, Mock, patch
import pytest
from agent_framework import AgentResponse, Message
@@ -97,6 +97,7 @@ def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMoc
credential=mock_credential,
allow_preview=True,
user_agent=get_user_agent(),
per_retry_policies=[ANY],
)
@@ -923,7 +923,7 @@ class TestFoundryEvals:
mock_project.get_openai_client.return_value = mock_oai
fe = FoundryEvals(project_client=mock_project, model="gpt-4o")
assert fe.name == "Microsoft Foundry"
mock_project.get_openai_client.assert_called_once()
mock_project.get_openai_client.assert_called_once_with()
def test_constructor_no_client_auto_creates_from_env(self) -> None:
"""When no client/project_client given, auto-creates FoundryChatClient from env."""
@@ -0,0 +1,10 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Foundry hosting-owned feature-usage indexes."""
FOUNDRY_TOOLBOX = 53
FOUNDRY_HOSTING = 55
@@ -1,12 +1,15 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AgentSession, SupportsAgentRun
from agent_framework._telemetry import mark_feature_used
from azure.ai.agentserver.core import get_request_context
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from starlette.requests import Request
from starlette.responses import Response, StreamingResponse
from typing_extensions import Any, AsyncGenerator
from ._feature_usage import FeatureIndex
class InvocationsHostServer(InvocationAgentServerHost):
"""An invocations server host for an agent."""
@@ -34,6 +37,7 @@ class InvocationsHostServer(InvocationAgentServerHost):
self._agent = agent
self._sessions: dict[str, AgentSession] = {}
self.invoke_handler(self._handle_invoke)
mark_feature_used(FeatureIndex.FOUNDRY_HOSTING)
def _partition_key(self) -> str:
"""Get the partition key for the current request.
@@ -26,6 +26,7 @@ from agent_framework import (
SupportsAgentRun,
WorkflowAgent,
)
from agent_framework._telemetry import mark_feature_used
from agent_framework.exceptions import AgentFrameworkException
from azure.ai.agentserver.responses import (
ResponseContext,
@@ -115,6 +116,8 @@ from azure.ai.agentserver.responses.streaming._builders import (
from mcp import McpError
from typing_extensions import Any
from ._feature_usage import FeatureIndex
logger = logging.getLogger(__name__)
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE = f"{MessageRole.__module__}:{MessageRole.__qualname__}"
@@ -485,6 +488,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
self._agent_init_lock = asyncio.Lock()
self.shutdown_handler(self._cleanup_agent)
self.response_handler(self._handle_response)
mark_feature_used(FeatureIndex.FOUNDRY_HOSTING)
async def _ensure_agent_ready(self) -> None:
"""Lazily enter the agent's async context exactly once.
@@ -19,9 +19,12 @@ from agent_framework import (
SkillsSource,
SkillsSourceContext,
)
from agent_framework._telemetry import mark_feature_used
from azure.ai.agentserver.core import get_request_context
from typing_extensions import override
from ._feature_usage import FeatureIndex
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Generator
from datetime import timedelta
@@ -218,6 +221,12 @@ class FoundryToolbox(MCPStreamableHTTPTool):
load_tools=load_tools,
)
@override
async def connect(self, *, reset: bool = False) -> None:
"""Connect to the toolbox and mark its first meaningful activation."""
await super().connect(reset=reset)
mark_feature_used(FeatureIndex.FOUNDRY_TOOLBOX)
@override
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
"""Get an authenticated MCP HTTP client.
@@ -9,11 +9,11 @@ from collections.abc import Callable
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, patch
import httpx
import pytest
from agent_framework import SkillsProvider, SkillsSourceContext, SupportsAgentRun
from agent_framework import MCPStreamableHTTPTool, SkillsProvider, SkillsSourceContext, SupportsAgentRun
from azure.ai.agentserver.core import (
FoundryAgentRequestContext,
reset_request_context,
@@ -21,6 +21,7 @@ from azure.ai.agentserver.core import (
)
from agent_framework_foundry_hosting import FoundryToolbox
from agent_framework_foundry_hosting._feature_usage import FeatureIndex
from agent_framework_foundry_hosting._toolbox import (
_FoundryToolboxSkillsSource,
_resolve_toolbox_endpoint,
@@ -119,6 +120,27 @@ def test_init_derives_name_and_defaults() -> None:
assert toolbox.load_prompts_flag is False
def test_toolbox_owns_feature_index_53() -> None:
assert FeatureIndex.FOUNDRY_TOOLBOX == 53
async def test_toolbox_marks_feature_on_successful_connect_not_construction() -> None:
with (
patch.object(MCPStreamableHTTPTool, "connect", new=AsyncMock()) as connect,
patch("agent_framework_foundry_hosting._toolbox.mark_feature_used") as mark_used,
):
toolbox = FoundryToolbox(
_FakeCredential(), # type: ignore
url="https://h/toolboxes/sales/mcp?api-version=v1",
)
mark_used.assert_not_called()
await toolbox.connect()
connect.assert_awaited_once_with(reset=False)
mark_used.assert_called_once_with(FeatureIndex.FOUNDRY_TOOLBOX)
async def test_auth_flow_injects_bearer_token() -> None:
cred = _FakeCredential("abc123")
auth = _ToolboxAuth(cred, "https://ai.azure.com/.default") # type: ignore
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Foundry Local-owned feature-usage indexes."""
FOUNDRY_LOCAL = 54
@@ -4,7 +4,7 @@ from __future__ import annotations
import sys
from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, Generic, Literal, cast, overload
from typing import Any, ClassVar, Generic, Literal, cast, overload
from agent_framework import (
ChatAndFunctionMiddlewareTypes,
@@ -27,6 +27,8 @@ from foundry_local.models import DeviceType, FoundryModelInfo
from openai import AsyncOpenAI
from pydantic import BaseModel
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
@@ -139,6 +141,8 @@ class FoundryLocalClient(
):
"""Foundry Local Chat completion class with middleware, telemetry, and function invocation support."""
_FEATURE_USAGE_INDEX: ClassVar[int | None] = FeatureIndex.FOUNDRY_LOCAL
@overload
def get_response(
self,
@@ -9,11 +9,16 @@ from agent_framework._settings import load_settings
from agent_framework.exceptions import SettingNotFoundError
from agent_framework.foundry import FoundryLocalClient
from agent_framework_foundry_local._feature_usage import FeatureIndex
from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings
# Settings Tests
def test_foundry_local_owns_foundry_local_feature() -> None:
assert FoundryLocalClient._FEATURE_USAGE_INDEX is FeatureIndex.FOUNDRY_LOCAL
def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[str, str]) -> None:
"""Test FoundryLocalSettings initialization from environment variables."""
settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_")
@@ -29,7 +29,7 @@ from agent_framework import (
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._telemetry import get_user_agent, mark_feature_used
from agent_framework._types import _get_data_bytes # type: ignore[reportPrivateUsage]
from agent_framework.exceptions import ContentError
from agent_framework.observability import ChatTelemetryLayer
@@ -38,6 +38,8 @@ from google.auth.credentials import Credentials
from google.genai import types
from pydantic import BaseModel
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 13):
from typing import TypeVar # pragma: no cover
else:
@@ -539,6 +541,7 @@ class RawGeminiChatClient(
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
validated = await self._validate_options(options)
model, contents, config = self._prepare_request(messages, validated)
mark_feature_used(FeatureIndex.GEMINI)
generate_content_stream = cast(
Callable[..., Awaitable[AsyncIterable[types.GenerateContentResponse]]],
cast(Any, self._genai_client.aio.models).generate_content_stream,
@@ -555,6 +558,7 @@ class RawGeminiChatClient(
async def _get_response() -> ChatResponse:
validated = await self._validate_options(options)
model, contents, config = self._prepare_request(messages, validated)
mark_feature_used(FeatureIndex.GEMINI)
raw = await self._genai_client.aio.models.generate_content(model=model, contents=contents, config=config) # type: ignore[arg-type]
return self._process_generate_response(raw, response_format=validated.get("response_format"))
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Gemini-owned feature-usage indexes."""
GEMINI = 59
@@ -17,6 +17,7 @@ from pydantic import BaseModel
from typing_extensions import NotRequired, TypedDict
from agent_framework_gemini import GeminiChatClient, GeminiChatOptions, RawGeminiChatClient, ThinkingConfig
from agent_framework_gemini._feature_usage import FeatureIndex
def _has_gemini_integration_credentials() -> bool:
@@ -369,8 +370,10 @@ async def test_get_response_returns_text() -> None:
client, mock = _make_gemini_client()
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hello!")]))
response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])])
with patch("agent_framework_gemini._chat_client.mark_feature_used") as mark_feature_used:
response = await client.get_response(messages=[Message(role="user", contents=[Content.from_text("Hi")])])
mark_feature_used.assert_called_once_with(FeatureIndex.GEMINI)
assert response.messages[0].text == "Hello!"
@@ -29,6 +29,7 @@ from agent_framework import (
normalize_messages,
)
from agent_framework._settings import load_settings
from agent_framework._telemetry import mark_feature_used
from agent_framework._tools import FunctionTool, ToolTypes
from agent_framework._types import (
AgentRunInputs,
@@ -38,6 +39,8 @@ from agent_framework._types import (
from agent_framework.exceptions import AgentException, ContentError
from agent_framework.observability import AgentTelemetryLayer
from ._feature_usage import FeatureIndex
if sys.version_info >= (3, 11):
from typing import Self # pragma: no cover
else:
@@ -666,6 +669,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
unsubscribe = copilot_session.on(usage_event_handler)
try:
mark_feature_used(FeatureIndex.GITHUB_COPILOT)
response_event = await copilot_session.send_and_wait(prompt, attachments=attachments, timeout=timeout)
except Exception as ex:
raise AgentException(f"GitHub Copilot request failed: {ex}") from ex
@@ -852,6 +856,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
unsubscribe = copilot_session.on(event_handler)
try:
mark_feature_used(FeatureIndex.GITHUB_COPILOT)
await copilot_session.send(prompt, attachments=attachments)
while (item := await queue.get()) is not None:
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""GitHub Copilot-owned feature-usage indexes."""
GITHUB_COPILOT = 64
@@ -39,6 +39,7 @@ from copilot.session_events import (
from copilot.tools import ToolInvocation, ToolResult
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions, RawGitHubCopilotAgent
from agent_framework_github_copilot._feature_usage import FeatureIndex
def copilot_options(options: GitHubCopilotOptions) -> GitHubCopilotOptions:
@@ -431,8 +432,10 @@ class TestGitHubCopilotAgentRun:
mock_session.send_and_wait.return_value = assistant_message_event
agent = GitHubCopilotAgent(client=mock_client)
response = await agent.run("Hello")
with patch("agent_framework_github_copilot._agent.mark_feature_used") as mark_feature_used:
response = await agent.run("Hello")
mark_feature_used.assert_called_once_with(FeatureIndex.GITHUB_COPILOT)
assert isinstance(response, AgentResponse)
assert len(response.messages) == 1
assert response.messages[0].role == "assistant"
@@ -21,12 +21,15 @@ from agent_framework import (
Workflow,
WorkflowRunResult,
)
from agent_framework._telemetry import mark_feature_used
from agent_framework_hosting import AgentRunArgs
from google.protobuf.json_format import MessageToDict, ParseDict
from google.protobuf.struct_pb2 import Value
from pydantic import TypeAdapter
from pydantic.errors import PydanticSchemaGenerationError
from ._feature_usage import FeatureIndex
logger = logging.getLogger("agent_framework.hosting.a2a")
_BINARY_MODE = "application/octet-stream"
@@ -189,6 +192,7 @@ def a2a_to_run(
ValueError: If the message has no supported content parts or contains
a part outside ``input_modes``.
"""
mark_feature_used(FeatureIndex.HOSTING_A2A)
if input_modes is not None:
_validate_part_modes(message.parts, input_modes, "input")
@@ -279,6 +283,7 @@ def a2a_from_run(
ValueError: If Agent Framework data content contains an invalid data URI
or produces a part outside ``output_modes``.
"""
mark_feature_used(FeatureIndex.HOSTING_A2A)
items: Sequence[Message | AgentResponseUpdate] = result.messages if isinstance(result, AgentResponse) else [result]
parts: list[Part] = []
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""A2A hosting-owned feature-usage indexes."""
HOSTING_A2A = 84
@@ -9,10 +9,12 @@ from collections.abc import Collection, Mapping
from typing import Any, Generic, TypeVar, cast
from agent_framework import AgentResponse, Message, SupportsAgentRun
from agent_framework._telemetry import mark_feature_used
from agent_framework_hosting import AgentRunArgs, AgentState
from mcp import types
from ._conversion import mcp_from_run, mcp_to_run
from ._feature_usage import FeatureIndex
AgentT = TypeVar("AgentT", bound=SupportsAgentRun)
@@ -86,6 +88,7 @@ class AgentMCPTool(Generic[AgentT]):
async def list_tools(self) -> list[types.Tool]:
"""Return the native MCP tool definition for the target agent."""
mark_feature_used(FeatureIndex.HOSTING_MCP)
target = await self.state.get_target()
return [self._tool_for_target(target)]
@@ -145,6 +148,7 @@ class AgentMCPTool(Generic[AgentT]):
Raises:
ValueError: If the tool name or configured session id is invalid.
"""
mark_feature_used(FeatureIndex.HOSTING_MCP)
target = await self.state.get_target()
tool = self._tool_for_target(target)
if name != tool.name:
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""MCP hosting-owned feature-usage indexes."""
HOSTING_MCP = 85
@@ -9,11 +9,13 @@ from collections.abc import Mapping
from typing import Any, Generic, TypeVar, cast
from agent_framework import AgentResponse, Message, Workflow, WorkflowRunResult
from agent_framework._telemetry import mark_feature_used
from agent_framework_hosting import WorkflowState
from mcp import types
from pydantic import TypeAdapter
from ._conversion import mcp_from_run
from ._feature_usage import FeatureIndex
WorkflowT = TypeVar("WorkflowT", bound=Workflow)
@@ -53,6 +55,7 @@ class WorkflowMCPTool(Generic[WorkflowT]):
async def list_tools(self) -> list[types.Tool]:
"""Return the native MCP tool definition for the target workflow."""
mark_feature_used(FeatureIndex.HOSTING_MCP)
workflow = await self.state.get_target()
return [self._tool_for_workflow(workflow)]
@@ -134,6 +137,7 @@ class WorkflowMCPTool(Generic[WorkflowT]):
Raises:
ValueError: If the tool name or workflow input contract is invalid.
"""
mark_feature_used(FeatureIndex.HOSTING_MCP)
workflow = await self.state.get_target()
tool = self._tool_for_workflow(workflow)
if name != tool.name:
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""OpenAI Responses hosting-owned feature-usage indexes."""
HOSTING_RESPONSES = 86
@@ -21,6 +21,7 @@ from collections.abc import AsyncIterator, Mapping, Sequence
from typing import Any, cast
from agent_framework import AgentResponse, AgentResponseUpdate, ChatOptions, Content, Message, ResponseStream
from agent_framework._telemetry import mark_feature_used
from agent_framework_hosting import AgentRunArgs
from openai.types.responses import (
Response as OpenAIResponse,
@@ -37,6 +38,8 @@ from openai.types.responses import (
)
from pydantic import TypeAdapter, ValidationError
from ._feature_usage import FeatureIndex
_RESPONSE_OUTPUT_ITEM_ADAPTER: TypeAdapter[Any] = TypeAdapter(ResponseOutputItem)
# OpenAI Responses field name → Agent Framework ChatOptions field name.
@@ -179,6 +182,7 @@ def responses_to_run(body: Mapping[str, Any]) -> AgentRunArgs:
Raises:
ValueError: If the request body has invalid ``input``.
"""
mark_feature_used(FeatureIndex.HOSTING_RESPONSES)
messages = messages_from_responses_input(body.get("input"))
options: dict[str, Any] = {}
for key, value in body.items():
@@ -211,6 +215,7 @@ def responses_from_run(
Returns:
Responses-compatible JSON payload.
"""
mark_feature_used(FeatureIndex.HOSTING_RESPONSES)
output_items = _result_to_output_items(result, status="completed")
response_kwargs: dict[str, Any] = {
"id": response_id,
@@ -894,6 +899,7 @@ async def responses_from_streaming_run(
model: str | None = None
updates: list[AgentResponseUpdate] = []
try:
mark_feature_used(FeatureIndex.HOSTING_RESPONSES)
async for update in stream:
updates.append(update)
if model is None:
@@ -0,0 +1,9 @@
# Copyright (c) Microsoft. All rights reserved.
from enum import IntEnum
class FeatureIndex(IntEnum):
"""Telegram hosting-owned feature-usage indexes."""
HOSTING_TELEGRAM = 87
@@ -19,8 +19,11 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import Any, cast
from agent_framework import ChatOptions, Content, Message
from agent_framework._telemetry import mark_feature_used
from agent_framework_hosting import AgentRunArgs
from ._feature_usage import FeatureIndex
# Telegram media fields whose objects carry a `file_id` (and, except photos,
# a `mime_type`) directly, mapped to the MIME type Telegram uses when the
# object omits `mime_type` (voice notes are always OGG/Opus, for example).
@@ -321,6 +324,7 @@ async def telegram_to_run(
ValueError: If the update has no actionable message/callback data, or
a message has no text, caption, or resolvable media.
"""
mark_feature_used(FeatureIndex.HOSTING_TELEGRAM)
message = _inner_message(update)
if message is not None:
contents = await _contents_from_message(message, resolve_file_url)

Some files were not shown because too many files have changed in this diff Show More