Skip to content
6 changes: 5 additions & 1 deletion python/packages/foundry/agent_framework_foundry/_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
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 agent_framework_foundry._oauth_helpers import parse_oauth_consent_output_items, try_parse_oauth_consent_event

from ._constants import FOUNDRY_HOSTED_AGENT_SESSION_ID_KEY
from ._feature_usage import (
Expand Down Expand Up @@ -426,6 +426,10 @@ def _parse_response_from_openai(
options: dict[str, Any],
) -> Any:
parsed_response = super()._parse_response_from_openai(response, options)
if parsed_response.messages:
parsed_response.messages[0].contents.extend(
parse_oauth_consent_output_items(getattr(response, "output", None))
)
if agent_session_id := _extract_foundry_hosted_agent_session_id(response):
parsed_response.additional_properties["agent_session_id"] = agent_session_id
return parsed_response
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
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 agent_framework_foundry._oauth_helpers import parse_oauth_consent_output_items, try_parse_oauth_consent_event

from ._feature_usage import (
FeatureIndex,
Expand Down Expand Up @@ -346,6 +346,9 @@ def _parse_response_from_openai(
if getattr(item, "type", None) != "reasoning":
continue
self._attach_foundry_reasoning_replay_item(chat_response.messages[0].contents, item)
chat_response.messages[0].contents.extend(
parse_oauth_consent_output_items(getattr(response, "output", None))
)
Comment thread
eavanvalkenburg marked this conversation as resolved.
return chat_response

@override
Expand Down
54 changes: 37 additions & 17 deletions python/packages/foundry/agent_framework_foundry/_oauth_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import logging
from collections.abc import Iterable
from typing import Any
from urllib.parse import urlparse

Expand Down Expand Up @@ -48,28 +49,47 @@ def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate
else:
return None

return ChatResponseUpdate(
contents=_oauth_consent_contents(raw_item, consent_link),
role="assistant",
model=model,
raw_representation=event,
)


def parse_oauth_consent_output_items(output: Iterable[Any] | None) -> list[Content]:
"""Parse ``oauth_consent_request`` items from a non-streaming response's ``output``.

Non-streaming responses carry the consent request as an output item instead of
a stream event, so it has to be surfaced from the completed response as well.
"""
contents: list[Content] = []
for item in output or ():
if getattr(item, "type", None) == "oauth_consent_request":
contents.extend(_oauth_consent_contents(item, getattr(item, "consent_link", None) or ""))
return contents


def _oauth_consent_contents(raw_item: Any, consent_link: str) -> list[Content]:
"""Build the consent content for an oauth_consent_request item, validating its link."""
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,
content = Content.from_oauth_consent_request(
consent_link=consent_link,
raw_representation=raw_item,
)

return ChatResponseUpdate(
contents=contents,
role="assistant",
model=model,
raw_representation=event,
# The consent request is a user-input request: workflows (``AgentExecutor``) register it
# under its id, so keep the provider item id instead of leaving it empty.
raw_id = getattr(raw_item, "id", None)
if isinstance(raw_id, str) and raw_id:
content.id = raw_id
return [content]
logger.warning(
"Received oauth_consent_request output without valid consent_link (item id=%s)",
item_id,
)
return []
Comment thread
eavanvalkenburg marked this conversation as resolved.
38 changes: 38 additions & 0 deletions python/packages/foundry/tests/foundry/test_foundry_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2006,6 +2006,44 @@ def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
assert update.raw_representation is mock_event


async def test_run_non_streaming_surfaces_oauth_consent_request() -> None:
"""A non-streaming agent run surfaces an oauth_consent_request output item instead of an empty answer."""

def handle_request(request: Any) -> Any:
return _OPENAI_HTTPX.Response(
200,
json={
"id": "resp_consent",
"object": "response",
"created_at": 0,
"model": "gpt-5.4",
"status": "incomplete",
"output": [
{
"type": "oauth_consent_request",
"id": "oauth-item-1",
"consent_link": "https://consent-host.example.com/login?data=abc123",
"server_label": "github",
}
],
},
)

async with AsyncOpenAI(
api_key="test-key",
http_client=DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(handle_request)),
max_retries=0,
) as async_client:
project_client = MagicMock()
project_client.get_openai_client.return_value = async_client
agent = FoundryAgent(project_client=project_client, agent_name="toolbox-agent")
response = await agent.run("List my repos")

consent_contents = [c for m in response.messages for c in m.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_client_model_not_set_from_openai_chat_model(monkeypatch: pytest.MonkeyPatch) -> None:
"""client.model must be empty when OPENAI_CHAT_MODEL is set."""
monkeypatch.setenv("OPENAI_CHAT_MODEL", "gpt-4.1-from-env")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1983,6 +1983,44 @@ def test_parse_chunk_surfaces_oauth_consent_requested_event() -> None:
assert update.raw_representation is mock_event


async def test_get_response_non_streaming_surfaces_oauth_consent_request() -> None:
"""A non-streaming response carrying an oauth_consent_request output item surfaces the consent link."""

def handle_request(request: Any) -> Any:
return _OPENAI_HTTPX.Response(
200,
json={
"id": "resp_consent",
"object": "response",
"created_at": 0,
"model": "test-model",
"status": "incomplete",
"output": [
{
"type": "oauth_consent_request",
"id": "oauth-item-1",
"consent_link": "https://consent-host.example.com/login?data=abc123",
"server_label": "github",
}
],
},
)

async with AsyncOpenAI(
api_key="test-key",
base_url="https://example.test/v1",
http_client=DefaultAsyncHttpxClient(transport=_OPENAI_HTTPX.MockTransport(handle_request)),
) as async_client:
project_client = MagicMock()
project_client.get_openai_client.return_value = async_client
client = FoundryChatClient(project_client=project_client, model="test-model")
response = await client.get_response([Message(role="user", contents=["List my repos"])])

consent_contents = [c for m in response.messages for c in m.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_agent_accepts_foundry_chat_clients() -> None:
mock_project = MagicMock()
mock_openai = _make_mock_openai_client()
Expand Down
141 changes: 138 additions & 3 deletions python/packages/foundry/tests/foundry/test_oauth_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,29 @@
from __future__ import annotations

import logging
from typing import Any
from collections.abc import AsyncIterable, Awaitable
from typing import Any, Literal, overload
from unittest.mock import MagicMock

import pytest

from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
from agent_framework import (
AgentExecutor,
AgentResponse,
AgentResponseUpdate,
AgentRunInputs,
AgentSession,
BaseAgent,
Content,
Message,
ResponseStream,
WorkflowBuilder,
)

from agent_framework_foundry._oauth_helpers import (
_validate_consent_link,
parse_oauth_consent_output_items,
try_parse_oauth_consent_event,
)

# region _validate_consent_link tests

Expand Down Expand Up @@ -162,3 +179,121 @@ def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture)


# endregion


# region consent request id


def _make_output_item(*, item_id: Any = "oauth-item-1", consent_link: str = "https://consent.example.com/auth") -> Any:
"""Create a mock non-streaming ``oauth_consent_request`` output item."""
item = MagicMock()
item.type = "oauth_consent_request"
item.consent_link = consent_link
item.id = item_id
return item


def test_output_item_added_keeps_provider_item_id() -> None:
"""The consent content carries the provider item id, as user-input requests need one."""
update = try_parse_oauth_consent_event(_make_output_item_event(item_id="oauth-item-42"), "test-model")

assert update is not None
assert [c.id for c in update.contents if c.type == "oauth_consent_request"] == ["oauth-item-42"]


def test_top_level_consent_requested_event_keeps_event_id() -> None:
"""A top-level ``response.oauth_consent_requested`` event keeps its id on the content."""
update = try_parse_oauth_consent_event(_make_top_level_event(event_id="consent-event-7"), "test-model")

assert update is not None
assert [c.id for c in update.contents if c.type == "oauth_consent_request"] == ["consent-event-7"]


def test_non_streaming_output_item_keeps_provider_item_id() -> None:
"""Non-streaming output items keep the provider item id on the consent content."""
contents = parse_oauth_consent_output_items([_make_output_item(item_id="oauth-item-9")])

assert len(contents) == 1
assert contents[0].type == "oauth_consent_request"
assert contents[0].user_input_request is True
assert contents[0].id == "oauth-item-9"


def test_consent_content_without_string_id_has_no_id() -> None:
"""A provider item without a usable id does not get a made-up one."""
contents = parse_oauth_consent_output_items([_make_output_item(item_id=None)])

assert len(contents) == 1
assert contents[0].id is None


class _ConsentEmittingAgent(BaseAgent):
"""Agent that returns the consent content parsed from a Foundry output item."""

def __init__(self, *, item_id: str, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._item_id = item_id

def _consent_contents(self) -> list[Content]:
return parse_oauth_consent_output_items([_make_output_item(item_id=self._item_id)])

@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[False] = ...,
session: AgentSession | None = ...,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]]: ...

@overload
def run(
self,
messages: AgentRunInputs | None = ...,
*,
stream: Literal[True],
session: AgentSession | None = ...,
**kwargs: Any,
) -> ResponseStream[AgentResponseUpdate, AgentResponse[Any]]: ...

def run(
self,
messages: AgentRunInputs | None = None,
*,
stream: bool = False,
session: AgentSession | None = None,
**kwargs: Any,
) -> Awaitable[AgentResponse[Any]] | ResponseStream[AgentResponseUpdate, AgentResponse[Any]]:
contents = self._consent_contents()

if stream:

async def _stream() -> AsyncIterable[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=contents, role="assistant")

return ResponseStream(_stream(), finalizer=AgentResponse.from_updates)

async def _run() -> AgentResponse:
return AgentResponse(messages=[Message("assistant", contents)])

return _run()


@pytest.mark.parametrize("stream", [False, True])
async def test_workflow_pauses_for_oauth_consent_request(stream: bool) -> None:
"""In a workflow, the consent request becomes a ``request_info`` event keyed by the provider item id."""
agent = _ConsentEmittingAgent(item_id="oauth-item-wf", id="consent_agent", name="ConsentAgent")
workflow = WorkflowBuilder(start_executor=AgentExecutor(agent, id="consent_exec")).build()

if stream:
events = [event async for event in workflow.run("connect my calendar", stream=True)]
else:
events = list(await workflow.run("connect my calendar"))

request_info_events = [event for event in events if event.type == "request_info"]
assert len(request_info_events) == 1
assert request_info_events[0].request_id == "oauth-item-wf"


# endregion
Loading