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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions python/packages/foundry/agent_framework_foundry/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
AgentMiddlewareLayer,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
ChatResponseUpdate,
ContextProvider,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
Expand All @@ -34,6 +35,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

from ._tools import sanitize_foundry_response_tool

if sys.version_info >= (3, 13):
Expand Down Expand Up @@ -309,6 +312,20 @@ 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]],
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, seen_reasoning_delta_item_ids)

@override
def _prepare_tools_for_openai(
self,
Expand Down
17 changes: 17 additions & 0 deletions python/packages/foundry/agent_framework_foundry/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatMiddlewareLayer,
ChatResponseUpdate,
Content,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
Expand All @@ -33,6 +34,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

from ._tools import fetch_toolbox, sanitize_foundry_response_tool

if sys.version_info >= (3, 13):
Expand Down Expand Up @@ -237,6 +240,20 @@ def _prepare_tools_for_openai(
response_tools = super()._prepare_tools_for_openai(tools)
return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]

@override
def _parse_chunk_from_openai(
self,
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, seen_reasoning_delta_item_ids)

Comment thread
giles17 marked this conversation as resolved.
async def configure_azure_monitor(
self,
enable_sensitive_data: bool = False,
Expand Down
75 changes: 75 additions & 0 deletions python/packages/foundry/agent_framework_foundry/_oauth_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# 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 _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
or a top-level ``response.oauth_consent_requested`` event,
or ``None`` so the caller can fall through to the base implementation.
"""
consent_link: str = ""
raw_item: Any = None

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":
raw_item = event
consent_link = getattr(event, "consent_link", None) or ""
else:
return None

item_id = getattr(raw_item, "id", "<unknown>")

if 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=raw_item,
)
)
else:
logger.warning(
"Received oauth_consent_request output without valid consent_link (item id=%s)",
item_id,
)

return ChatResponseUpdate(
contents=contents,
role="assistant",
model=model,
raw_representation=event,
)
156 changes: 156 additions & 0 deletions python/packages/foundry/tests/foundry/test_foundry_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import pytest
from agent_framework import AgentResponse, ChatContext, ChatMiddleware, Message, tool
from agent_framework_openai._chat_client import RawOpenAIChatClient
from azure.core.exceptions import ResourceNotFoundError
from azure.identity import AzureCliCredential

Expand Down Expand Up @@ -504,3 +505,158 @@ 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"
Comment thread
giles17 marked this conversation as resolved.
assert update.role == "assistant"
assert update.raw_representation is mock_event


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
Comment thread
giles17 marked this conversation as resolved.


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, {}, {}, None)


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
Loading
Loading