From 425c176d39a42d57f2e7fb5d7bc0d169553f422f Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 2 Apr 2026 20:20:24 +0000 Subject: [PATCH 1/7] Fix Foundry clients not surfacing oauth_consent_request events (#5054) Override _parse_chunk_from_openai in both RawFoundryChatClient and RawFoundryAgentChatClient to intercept response.output_item.added events with item.type == 'oauth_consent_request'. The consent link is validated (HTTPS required) and converted to Content.from_oauth_consent_request, which the AG-UI layer already knows how to emit as a CUSTOM event. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/agent_framework_foundry/_agent.py | 32 ++++++++ .../agent_framework_foundry/_chat_client.py | 31 +++++++ .../tests/foundry/test_foundry_agent.py | 79 ++++++++++++++++++ .../tests/foundry/test_foundry_chat_client.py | 82 +++++++++++++++++++ 4 files changed, 224 insertions(+) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index a499abf6f4..fe68acf635 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -19,6 +19,8 @@ AgentMiddlewareLayer, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ChatResponseUpdate, + Content, ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -300,6 +302,36 @@ def _check_model_presence(self, options: dict[str, Any]) -> None: """Skip model check — model is configured on the Foundry agent.""" pass + @override + def _parse_chunk_from_openai( + self, + event: Any, + options: dict[str, Any], + function_call_ids: dict[int, tuple[str, str]], + ) -> ChatResponseUpdate: + """Parse streaming event, intercepting oauth_consent_request items.""" + if event.type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": + consent_link = getattr(event.item, "consent_link", None) or "" + if consent_link and not consent_link.startswith("https://"): + logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", event.item) + consent_link = "" + contents: list[Content] = [] + if consent_link: + contents.append( + Content.from_oauth_consent_request( + consent_link=consent_link, + raw_representation=event.item, + ) + ) + else: + logger.warning("Received oauth_consent_request output without consent_link: %s", event.item) + return ChatResponseUpdate( + contents=contents, + role="assistant", + raw_representation=event, + ) + return super()._parse_chunk_from_openai(event, options, function_call_ids) + def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: """Extract system/developer messages as instructions for Azure AI. diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index b8ae5ce398..5339ad26b1 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -10,6 +10,7 @@ from agent_framework import ( AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, + ChatResponseUpdate, Content, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -217,6 +218,36 @@ def _check_model_presence(self, options: dict[str, Any]) -> None: raise ValueError("model must be a non-empty string") options["model"] = self.model + @override + def _parse_chunk_from_openai( + self, + event: Any, + options: dict[str, Any], + function_call_ids: dict[int, tuple[str, str]], + ) -> ChatResponseUpdate: + """Parse streaming event, intercepting oauth_consent_request items.""" + if event.type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": + consent_link = getattr(event.item, "consent_link", None) or "" + if consent_link and not consent_link.startswith("https://"): + logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", event.item) + consent_link = "" + contents: list[Content] = [] + if consent_link: + contents.append( + Content.from_oauth_consent_request( + consent_link=consent_link, + raw_representation=event.item, + ) + ) + else: + logger.warning("Received oauth_consent_request output without consent_link: %s", event.item) + return ChatResponseUpdate( + contents=contents, + role="assistant", + raw_representation=event, + ) + return super()._parse_chunk_from_openai(event, options, function_call_ids) + async def configure_azure_monitor( self, enable_sensitive_data: bool = False, diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 09a31f941b..6b6501b418 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -465,3 +465,82 @@ async def test_foundry_agent_custom_client_run() -> None: assert isinstance(response, AgentResponse) assert response.text is not None assert "response test" in response.text.lower() + + +def test_parse_chunk_surfaces_oauth_consent_request() -> None: + """An oauth_consent_request output item surfaces as Content with consent_link.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "https://consent-host.example.com/login?data=abc123" + mock_item.id = "oauth-item-1" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + + +def test_parse_chunk_skips_non_https_oauth_consent() -> None: + """An oauth_consent_request with a non-HTTPS link is rejected.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "http://insecure.example.com/login" + mock_item.id = "oauth-item-2" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_missing_consent_link() -> None: + """An oauth_consent_request without a consent_link produces no content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = None + mock_item.id = "oauth-item-3" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index a4e9afdd3e..484a47fc98 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -810,3 +810,85 @@ def test_get_mcp_tool_with_connection_id() -> None: description="GitHub MCP via Foundry", ) assert tool_obj is not None + + +def test_parse_chunk_surfaces_oauth_consent_request() -> None: + """An oauth_consent_request output item surfaces as Content with consent_link.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "https://consent-host.example.com/login?data=abc123" + mock_item.id = "oauth-item-1" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + + +def test_parse_chunk_skips_non_https_oauth_consent() -> None: + """An oauth_consent_request with a non-HTTPS link is rejected.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "http://insecure.example.com/login" + mock_item.id = "oauth-item-2" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_handles_missing_consent_link() -> None: + """An oauth_consent_request without a consent_link produces no content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = None + mock_item.id = "oauth-item-3" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 From cca1aff2c8285b8c65d5a84270ea29ff72e7b99f Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 2 Apr 2026 20:38:02 +0000 Subject: [PATCH 2/7] Address PR review feedback for #5054 OAuth consent parsing - Extract shared helper (try_parse_oauth_consent_event) to avoid duplicated logic between RawFoundryChatClient and RawFoundryAgentChatClient - Use urllib.parse.urlparse() for HTTPS validation instead of case-sensitive startswith check - Sanitize log messages to avoid leaking consent_link tokens; log only item id - Add model=self.model to ChatResponseUpdate to match parent behavior - Add assertions on role, raw_representation, and model in happy-path tests - Add test for empty-string consent_link - Add test verifying non-oauth events delegate to super() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../foundry/agent_framework_foundry/_agent.py | 25 ++------ .../agent_framework_foundry/_chat_client.py | 25 ++------ .../agent_framework_foundry/_oauth_helpers.py | 55 ++++++++++++++++++ .../tests/foundry/test_foundry_agent.py | 53 +++++++++++++++++ .../tests/foundry/test_foundry_chat_client.py | 57 +++++++++++++++++++ 5 files changed, 175 insertions(+), 40 deletions(-) create mode 100644 python/packages/foundry/agent_framework_foundry/_oauth_helpers.py diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index fe68acf635..5b9adadb36 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -33,6 +33,8 @@ from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient + +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential @@ -310,26 +312,9 @@ def _parse_chunk_from_openai( function_call_ids: dict[int, tuple[str, str]], ) -> ChatResponseUpdate: """Parse streaming event, intercepting oauth_consent_request items.""" - if event.type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": - consent_link = getattr(event.item, "consent_link", None) or "" - if consent_link and not consent_link.startswith("https://"): - logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", event.item) - consent_link = "" - contents: list[Content] = [] - if consent_link: - contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=event.item, - ) - ) - else: - logger.warning("Received oauth_consent_request output without consent_link: %s", event.item) - return ChatResponseUpdate( - contents=contents, - role="assistant", - raw_representation=event, - ) + update = try_parse_oauth_consent_event(event, self.model) + if update is not None: + return update return super()._parse_chunk_from_openai(event, options, function_call_ids) def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]: diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 5339ad26b1..1e79b476a7 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -20,6 +20,8 @@ from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient + +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event from azure.ai.projects.models import ( AutoCodeInterpreterToolParam, CodeInterpreterTool, @@ -226,26 +228,9 @@ def _parse_chunk_from_openai( function_call_ids: dict[int, tuple[str, str]], ) -> ChatResponseUpdate: """Parse streaming event, intercepting oauth_consent_request items.""" - if event.type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": - consent_link = getattr(event.item, "consent_link", None) or "" - if consent_link and not consent_link.startswith("https://"): - logger.warning("Skipping oauth_consent_request with non-HTTPS consent_link: %s", event.item) - consent_link = "" - contents: list[Content] = [] - if consent_link: - contents.append( - Content.from_oauth_consent_request( - consent_link=consent_link, - raw_representation=event.item, - ) - ) - else: - logger.warning("Received oauth_consent_request output without consent_link: %s", event.item) - return ChatResponseUpdate( - contents=contents, - role="assistant", - raw_representation=event, - ) + update = try_parse_oauth_consent_event(event, self.model) + if update is not None: + return update return super()._parse_chunk_from_openai(event, options, function_call_ids) async def configure_azure_monitor( diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py new file mode 100644 index 0000000000..3dcafb3a89 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from agent_framework import ChatResponseUpdate, Content + +logger = logging.getLogger(__name__) + + +def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None: + """Parse an oauth_consent_request from a streaming event, if present. + + Returns a ``ChatResponseUpdate`` when *event* is a + ``response.output_item.added`` carrying an ``oauth_consent_request`` item, + or ``None`` so the caller can fall through to the base implementation. + """ + if event.type != "response.output_item.added" or getattr(event.item, "type", None) != "oauth_consent_request": + return None + + item = event.item + consent_link = getattr(item, "consent_link", None) or "" + + if consent_link: + parsed = urlparse(consent_link) + if parsed.scheme.lower() != "https" or not parsed.netloc: + logger.warning( + "Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)", + getattr(item, "id", ""), + ) + consent_link = "" + + contents: list[Content] = [] + if consent_link: + contents.append( + Content.from_oauth_consent_request( + consent_link=consent_link, + raw_representation=item, + ) + ) + else: + logger.warning( + "Received oauth_consent_request output without valid consent_link (item id=%s)", + getattr(item, "id", ""), + ) + + return ChatResponseUpdate( + contents=contents, + role="assistant", + model=model, + raw_representation=event, + ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 6b6501b418..0365b8a2eb 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -11,6 +11,7 @@ import pytest from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool from azure.core.exceptions import ResourceNotFoundError +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.identity import AzureCliCredential from agent_framework_foundry._agent import ( @@ -492,6 +493,8 @@ def test_parse_chunk_surfaces_oauth_consent_request() -> None: consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] assert len(consent_contents) == 1 assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + assert update.role == "assistant" + assert update.raw_representation is mock_event def test_parse_chunk_skips_non_https_oauth_consent() -> None: @@ -544,3 +547,53 @@ def test_parse_chunk_handles_missing_consent_link() -> None: consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] assert len(consent_contents) == 0 + + + +def test_parse_chunk_handles_empty_string_consent_link() -> None: + """An oauth_consent_request with empty-string consent_link produces no content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "" + mock_item.id = "oauth-item-4" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: + """Non-oauth events are delegated to super()._parse_chunk_from_openai().""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_text.delta" + + with patch.object( + RawOpenAIChatClient, + "_parse_chunk_from_openai", + return_value=MagicMock(), + ) as mock_super: + client._parse_chunk_from_openai(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}) diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 484a47fc98..5c44a2a43d 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -21,6 +21,8 @@ from pydantic import BaseModel from pytest import param +from agent_framework_openai._chat_client import RawOpenAIChatClient + from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient @@ -838,6 +840,9 @@ def test_parse_chunk_surfaces_oauth_consent_request() -> None: consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] assert len(consent_contents) == 1 assert consent_contents[0].consent_link == "https://consent-host.example.com/login?data=abc123" + assert update.role == "assistant" + assert update.raw_representation is mock_event + assert update.model == "test-model" def test_parse_chunk_skips_non_https_oauth_consent() -> None: @@ -892,3 +897,55 @@ def test_parse_chunk_handles_missing_consent_link() -> None: consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] assert len(consent_contents) == 0 + + + +def test_parse_chunk_handles_empty_string_consent_link() -> None: + """An oauth_consent_request with empty-string consent_link produces no content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_item.added" + mock_item = MagicMock() + mock_item.type = "oauth_consent_request" + mock_item.consent_link = "" + mock_item.id = "oauth-item-4" + mock_event.item = mock_item + mock_event.output_index = 0 + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 0 + + +def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: + """Non-oauth events are delegated to super()._parse_chunk_from_openai().""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.output_text.delta" + + with patch.object( + RawOpenAIChatClient, + "_parse_chunk_from_openai", + return_value=MagicMock(), + ) as mock_super: + client._parse_chunk_from_openai(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}) From 7d77e4c9c2bb894c2c6a471597891eb3e5c89fd7 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 2 Apr 2026 20:42:37 +0000 Subject: [PATCH 3/7] Handle response.oauth_consent_requested top-level event (#5054) Add support for the top-level response.oauth_consent_requested stream event in addition to the response.output_item.added variant. The service may emit either form; handle both so the consent link is reliably surfaced. Extract _validate_consent_link helper within _oauth_helpers.py to reduce nesting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_foundry/_oauth_helpers.py | 44 +++++++++++++------ .../tests/foundry/test_foundry_agent.py | 25 +++++++++++ .../tests/foundry/test_foundry_chat_client.py | 26 +++++++++++ 3 files changed, 82 insertions(+), 13 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py index 3dcafb3a89..94e8f2e4a9 100644 --- a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -11,40 +11,58 @@ logger = logging.getLogger(__name__) +def _validate_consent_link(consent_link: str, item_id: str) -> str: + """Validate a consent link is HTTPS with a valid netloc. + + Returns the link unchanged if valid, or an empty string if not. + """ + parsed = urlparse(consent_link) + if parsed.scheme.lower() != "https" or not parsed.netloc: + logger.warning( + "Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)", + item_id, + ) + return "" + return consent_link + + def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None: """Parse an oauth_consent_request from a streaming event, if present. Returns a ``ChatResponseUpdate`` when *event* is a - ``response.output_item.added`` carrying an ``oauth_consent_request`` item, + ``response.output_item.added`` carrying an ``oauth_consent_request`` item + or a top-level ``response.oauth_consent_requested`` event, or ``None`` so the caller can fall through to the base implementation. """ - if event.type != "response.output_item.added" or getattr(event.item, "type", None) != "oauth_consent_request": + consent_link: str = "" + raw_item: Any = None + + if event.type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": + raw_item = event.item + consent_link = getattr(raw_item, "consent_link", None) or "" + elif event.type == "response.oauth_consent_requested": + raw_item = event + consent_link = getattr(event, "consent_link", None) or "" + else: return None - item = event.item - consent_link = getattr(item, "consent_link", None) or "" + item_id = getattr(raw_item, "id", "") if consent_link: - parsed = urlparse(consent_link) - if parsed.scheme.lower() != "https" or not parsed.netloc: - logger.warning( - "Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)", - getattr(item, "id", ""), - ) - consent_link = "" + consent_link = _validate_consent_link(consent_link, item_id) contents: list[Content] = [] if consent_link: contents.append( Content.from_oauth_consent_request( consent_link=consent_link, - raw_representation=item, + raw_representation=raw_item, ) ) else: logger.warning( "Received oauth_consent_request output without valid consent_link (item id=%s)", - getattr(item, "id", ""), + item_id, ) return ChatResponseUpdate( diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 0365b8a2eb..2753825bcd 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -597,3 +597,28 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: ) as mock_super: client._parse_chunk_from_openai(mock_event, {}, {}) mock_super.assert_called_once_with(mock_event, {}, {}) + + +def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: + """A top-level response.oauth_consent_requested event surfaces as Content.""" + + mock_project = MagicMock() + mock_project.get_openai_client.return_value = MagicMock() + + client = RawFoundryAgentChatClient( + project_client=mock_project, + agent_name="test-agent", + ) + + mock_event = MagicMock() + mock_event.type = "response.oauth_consent_requested" + mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz" + mock_event.id = "consent-event-1" + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz" + assert update.role == "assistant" + assert update.raw_representation is mock_event diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 5c44a2a43d..ae6e714e4a 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -949,3 +949,29 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: ) as mock_super: client._parse_chunk_from_openai(mock_event, {}, {}) mock_super.assert_called_once_with(mock_event, {}, {}) + + +def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: + """A top-level response.oauth_consent_requested event surfaces as Content.""" + + mock_project = MagicMock() + mock_openai = _make_mock_openai_client() + mock_project.get_openai_client.return_value = mock_openai + + client = RawFoundryChatClient( + project_client=mock_project, + model="test-model", + ) + + mock_event = MagicMock() + mock_event.type = "response.oauth_consent_requested" + mock_event.consent_link = "https://consent-host.example.com/authorize?code=xyz" + mock_event.id = "consent-event-1" + + update = client._parse_chunk_from_openai(mock_event, {}, {}) + + consent_contents = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent_contents) == 1 + assert consent_contents[0].consent_link == "https://consent-host.example.com/authorize?code=xyz" + assert update.role == "assistant" + assert update.raw_representation is mock_event From a544ca925f8eb596aa7c398c784f449de7301b4c Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 2 Apr 2026 20:43:37 +0000 Subject: [PATCH 4/7] Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event --- python/packages/foundry/agent_framework_foundry/_agent.py | 5 ++--- .../packages/foundry/agent_framework_foundry/_chat_client.py | 4 ++-- python/packages/foundry/tests/foundry/test_foundry_agent.py | 3 +-- .../foundry/tests/foundry/test_foundry_chat_client.py | 4 +--- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 5b9adadb36..0360e49c82 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -20,7 +20,6 @@ ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, ChatResponseUpdate, - Content, ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -33,11 +32,11 @@ from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient - -from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event + if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 1e79b476a7..de1601fdce 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -20,8 +20,6 @@ from agent_framework.observability import ChatTelemetryLayer from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient from azure.ai.projects.aio import AIProjectClient - -from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event from azure.ai.projects.models import ( AutoCodeInterpreterToolParam, CodeInterpreterTool, @@ -35,6 +33,8 @@ from azure.core.credentials import TokenCredential from azure.core.credentials_async import AsyncTokenCredential +from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event + if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover else: diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 2753825bcd..3ac08dabf7 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -10,8 +10,8 @@ import pytest from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool -from azure.core.exceptions import ResourceNotFoundError from agent_framework_openai._chat_client import RawOpenAIChatClient +from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential from agent_framework_foundry._agent import ( @@ -549,7 +549,6 @@ def test_parse_chunk_handles_missing_consent_link() -> None: assert len(consent_contents) == 0 - def test_parse_chunk_handles_empty_string_consent_link() -> None: """An oauth_consent_request with empty-string consent_link produces no content.""" diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index ae6e714e4a..1139256872 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -15,14 +15,13 @@ from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException from agent_framework_openai import OpenAIContentFilterException +from agent_framework_openai._chat_client import RawOpenAIChatClient from azure.core.exceptions import ResourceNotFoundError from azure.identity import AzureCliCredential from openai import BadRequestError from pydantic import BaseModel from pytest import param -from agent_framework_openai._chat_client import RawOpenAIChatClient - from agent_framework_foundry import FoundryChatClient, RawFoundryChatClient @@ -899,7 +898,6 @@ def test_parse_chunk_handles_missing_consent_link() -> None: assert len(consent_contents) == 0 - def test_parse_chunk_handles_empty_string_consent_link() -> None: """An oauth_consent_request with empty-string consent_link produces no content.""" From 5393ceda0e442de3e181c8a2f663c147bbbefb13 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 2 Apr 2026 20:51:03 +0000 Subject: [PATCH 5/7] Address review feedback: defensive getattr and dedicated helper tests (#5054) - Use getattr(event, 'type', None) in try_parse_oauth_consent_event for defensive access against malformed events without a type attribute - Add test_oauth_helpers.py with unit tests for _validate_consent_link and try_parse_oauth_consent_event covering edge cases: - HTTPS URL with empty netloc (https:///path) - Warning log messages for rejected consent links - Event objects missing 'type' attribute Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agent_framework_foundry/_oauth_helpers.py | 6 +- .../tests/foundry/test_oauth_helpers.py | 165 ++++++++++++++++++ 2 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 python/packages/foundry/tests/foundry/test_oauth_helpers.py diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py index 94e8f2e4a9..873d42c3d9 100644 --- a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -37,10 +37,12 @@ def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate consent_link: str = "" raw_item: Any = None - if event.type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": + event_type = getattr(event, "type", None) + + if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request": raw_item = event.item consent_link = getattr(raw_item, "consent_link", None) or "" - elif event.type == "response.oauth_consent_requested": + elif event_type == "response.oauth_consent_requested": raw_item = event consent_link = getattr(event, "consent_link", None) or "" else: diff --git a/python/packages/foundry/tests/foundry/test_oauth_helpers.py b/python/packages/foundry/tests/foundry/test_oauth_helpers.py new file mode 100644 index 0000000000..ce7e913b9a --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_oauth_helpers.py @@ -0,0 +1,165 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import logging +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event + + +# region _validate_consent_link tests + + +def test_validate_consent_link_accepts_valid_https() -> None: + """A valid HTTPS URL with a netloc passes validation.""" + link = "https://consent.example.com/auth?code=123" + assert _validate_consent_link(link, "item-1") == link + + +def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None: + """An HTTP link is rejected and a warning is logged.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("http://insecure.example.com/login", "item-2") + assert result == "" + assert "non-HTTPS" in caplog.text + assert "item-2" in caplog.text + + +def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None: + """An HTTPS URL with an empty netloc (e.g. https:///path) is rejected.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("https:///path", "item-3") + assert result == "" + assert "non-HTTPS" in caplog.text + assert "item-3" in caplog.text + + +def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None: + """A non-URL string is rejected.""" + with caplog.at_level(logging.WARNING): + result = _validate_consent_link("not-a-url", "item-4") + assert result == "" + + +# endregion + +# region try_parse_oauth_consent_event tests + + +def _make_output_item_event( + *, + item_type: str = "oauth_consent_request", + consent_link: Any = "https://consent.example.com/auth", + item_id: str = "oauth-item-1", +) -> MagicMock: + """Create a mock ``response.output_item.added`` event.""" + event = MagicMock() + event.type = "response.output_item.added" + item = MagicMock() + item.type = item_type + item.consent_link = consent_link + item.id = item_id + event.item = item + return event + + +def _make_top_level_event( + *, + consent_link: Any = "https://consent.example.com/authorize", + event_id: str = "consent-event-1", +) -> MagicMock: + """Create a mock ``response.oauth_consent_requested`` event.""" + event = MagicMock() + event.type = "response.oauth_consent_requested" + event.consent_link = consent_link + event.id = event_id + return event + + +def test_returns_none_for_unrelated_event() -> None: + """An event with a non-oauth type returns None.""" + event = MagicMock() + event.type = "response.output_text.delta" + assert try_parse_oauth_consent_event(event, "model-x") is None + + +def test_returns_none_for_event_without_type() -> None: + """An event object missing a 'type' attribute returns None.""" + event = object() # no type attribute + assert try_parse_oauth_consent_event(event, "model-x") is None + + +def test_parses_output_item_added_with_valid_link() -> None: + """A response.output_item.added event with a valid HTTPS link produces Content.""" + event = _make_output_item_event() + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert update.role == "assistant" + assert update.model == "test-model" + assert update.raw_representation is event + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent) == 1 + assert consent[0].consent_link == "https://consent.example.com/auth" + + +def test_parses_top_level_consent_requested_event() -> None: + """A response.oauth_consent_requested event produces Content.""" + event = _make_top_level_event() + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + consent = [c for c in update.contents if c.type == "oauth_consent_request"] + assert len(consent) == 1 + assert consent[0].consent_link == "https://consent.example.com/authorize" + + +def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None: + """A non-HTTPS consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "non-HTTPS" in caplog.text + + +def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None: + """A None consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link=None, item_id="item-none") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "without valid consent_link" in caplog.text + + +def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None: + """An empty-string consent_link produces an update with empty contents and logs a warning.""" + event = _make_output_item_event(consent_link="", item_id="item-empty") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "without valid consent_link" in caplog.text + + +def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None: + """An HTTPS URL with empty netloc (https:///path) is rejected.""" + event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc") + with caplog.at_level(logging.WARNING): + update = try_parse_oauth_consent_event(event, "test-model") + + assert update is not None + assert len(update.contents) == 0 + assert "non-HTTPS" in caplog.text + + +# endregion From 866b3e52e0e4b61557d607fb932997458eb155d9 Mon Sep 17 00:00:00 2001 From: Copilot Date: Thu, 2 Apr 2026 20:52:39 +0000 Subject: [PATCH 6/7] Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event --- python/packages/foundry/tests/foundry/test_oauth_helpers.py | 1 - 1 file changed, 1 deletion(-) diff --git a/python/packages/foundry/tests/foundry/test_oauth_helpers.py b/python/packages/foundry/tests/foundry/test_oauth_helpers.py index ce7e913b9a..2ab209e141 100644 --- a/python/packages/foundry/tests/foundry/test_oauth_helpers.py +++ b/python/packages/foundry/tests/foundry/test_oauth_helpers.py @@ -10,7 +10,6 @@ from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event - # region _validate_consent_link tests From 8eeaae25f3af689e0bfddf16eb6673ac2fdc313e Mon Sep 17 00:00:00 2001 From: Copilot Date: Tue, 21 Apr 2026 20:11:11 +0000 Subject: [PATCH 7/7] Fix mypy: match _parse_chunk_from_openai signature with superclass Add seen_reasoning_delta_item_ids parameter to _parse_chunk_from_openai overrides in both RawFoundryChatClient and RawFoundryAgentChatClient to match the updated superclass signature on main. Update super() calls and test assertions accordingly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- python/packages/foundry/agent_framework_foundry/_agent.py | 3 ++- .../packages/foundry/agent_framework_foundry/_chat_client.py | 3 ++- python/packages/foundry/tests/foundry/test_foundry_agent.py | 2 +- .../packages/foundry/tests/foundry/test_foundry_chat_client.py | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index 6c2daa113f..6a406d355e 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -318,12 +318,13 @@ def _parse_chunk_from_openai( event: Any, options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, ) -> ChatResponseUpdate: """Parse streaming event, intercepting oauth_consent_request items.""" update = try_parse_oauth_consent_event(event, self.model) if update is not None: return update - return super()._parse_chunk_from_openai(event, options, function_call_ids) + return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) @override def _prepare_tools_for_openai( diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index f2a7b74f05..f2f05f55f7 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -246,12 +246,13 @@ def _parse_chunk_from_openai( event: Any, options: dict[str, Any], function_call_ids: dict[int, tuple[str, str]], + seen_reasoning_delta_item_ids: set[str] | None = None, ) -> ChatResponseUpdate: """Parse streaming event, intercepting oauth_consent_request items.""" update = try_parse_oauth_consent_event(event, self.model) if update is not None: return update - return super()._parse_chunk_from_openai(event, options, function_call_ids) + return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids) async def configure_azure_monitor( self, diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index d5dfdd5afc..15a8b5dd49 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -634,7 +634,7 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: return_value=MagicMock(), ) as mock_super: client._parse_chunk_from_openai(mock_event, {}, {}) - mock_super.assert_called_once_with(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}, None) def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index 0eb449e3ff..8df27f7494 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -1024,7 +1024,7 @@ def test_parse_chunk_delegates_non_oauth_events_to_super() -> None: return_value=MagicMock(), ) as mock_super: client._parse_chunk_from_openai(mock_event, {}, {}) - mock_super.assert_called_once_with(mock_event, {}, {}) + mock_super.assert_called_once_with(mock_event, {}, {}, None) def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None: