Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions python/packages/ag-ui/agent_framework_ag_ui/_run_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@ def _emit_usage(content: Content) -> list[BaseEvent]:
return [CustomEvent(name="usage", value=usage_details)]


def _emit_oauth_consent(content: Content) -> list[BaseEvent]:
"""Emit an OAuth consent request as a custom event so frontends can render a consent link."""
consent_link = content.consent_link or ""
if not consent_link:
return []
return [CustomEvent(name="oauth_consent_request", value={"consent_link": consent_link})]


def _emit_content(
content: Any,
flow: FlowState,
Expand All @@ -374,5 +382,7 @@ def _emit_content(
return _emit_approval_request(content, flow, predictive_handler, require_confirmation)
if content_type == "usage":
return _emit_usage(content)
if content_type == "oauth_consent_request":
return _emit_oauth_consent(content)
logger.debug("Skipping unsupported content type in AG-UI emitter: %s", content_type)
return []
24 changes: 24 additions & 0 deletions python/packages/ag-ui/tests/ag_ui/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest
from ag_ui.core import (
CustomEvent,
TextMessageEndEvent,
TextMessageStartEvent,
)
Expand Down Expand Up @@ -834,3 +835,26 @@ def test_text_then_tool_flow(self):

assert len(start_events) == 2
assert len(end_events) == 2


def test_emit_oauth_consent_request():
"""Test that oauth_consent_request content emits a CustomEvent."""
content = Content.from_oauth_consent_request(
consent_link="https://login.microsoftonline.com/consent",
)
flow = FlowState()
events = _emit_content(content, flow)

assert len(events) == 1
assert isinstance(events[0], CustomEvent)
assert events[0].name == "oauth_consent_request"
assert events[0].value == {"consent_link": "https://login.microsoftonline.com/consent"}


def test_emit_oauth_consent_request_no_link():
"""Test that oauth_consent_request without a consent_link emits no events."""
content = Content("oauth_consent_request", user_input_request=True)
flow = FlowState()
events = _emit_content(content, flow)

assert len(events) == 0
58 changes: 58 additions & 0 deletions python/packages/azure-ai/agent_framework_azure_ai/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
BaseContextProvider,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
ChatResponse,
ChatResponseUpdate,
Content,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
Expand Down Expand Up @@ -579,6 +582,61 @@ def _get_current_conversation_id(self, options: Mapping[str, Any], **kwargs: Any
"""Get the current conversation ID from chat options or kwargs."""
return options.get("conversation_id") or kwargs.get("conversation_id") or self.conversation_id

@override
def _parse_response_from_openai(
self,
response: Any,
options: dict[str, Any],
) -> ChatResponse:
"""Parse an Azure AI Responses API response, handling Azure-specific output item types."""
result = super()._parse_response_from_openai(response, options)

for item in response.output:
if item.type == "oauth_consent_request":
consent_link = item.consent_link
if consent_link and result.messages:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this logic will mean it drops silently if the result.messages list is empty, probably not intended?

result.messages[0].contents.append(
Content.from_oauth_consent_request(
consent_link=consent_link,
raw_representation=item,
)
)
elif not consent_link:
logger.warning("Received oauth_consent_request output without consent_link: %s", item)

return result

@override
def _parse_chunk_from_openai(
self,
event: Any,
options: dict[str, Any],
function_call_ids: dict[int, tuple[str, str]],
) -> ChatResponseUpdate:
"""Parse an Azure AI streaming event, handling Azure-specific event types."""
# Intercept output_item.added events for Azure-specific item types
if event.type == "response.output_item.added" and event.item.type == "oauth_consent_request":
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this logic will silently ignore other content types? And that seems inconsistent with the non-streaming flow?

event_item = event.item
consent_link = 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",
model_id=self.model_id,
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]:
"""Prepare input from messages and convert system/developer messages to instructions."""
result: list[Message] = []
Expand Down
60 changes: 60 additions & 0 deletions python/packages/azure-ai/tests/test_azure_ai_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1774,3 +1774,63 @@ def test_get_image_generation_tool_with_options() -> None:


# endregion


# region OAuth Consent


def test_parse_chunk_with_oauth_consent_request(mock_project_client: MagicMock) -> None:
"""Test that a streaming oauth_consent_request output item is parsed into oauth_consent_request content.

This reproduces the bug from issue #3950 where the event was logged as "Unparsed event"
and silently discarded, causing the agent run to complete with zero content.
"""
client = AzureAIClient(project_client=mock_project_client, agent_name="test")
chat_options: dict[str, Any] = {}
function_call_ids: dict[int, tuple[str, str]] = {}

mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc123"

mock_event = MagicMock()
mock_event.type = "response.output_item.added"
mock_event.item = mock_item
mock_event.output_index = 0

update = client._parse_chunk_from_openai(mock_event, chat_options, function_call_ids)

assert len(update.contents) == 1
consent_content = update.contents[0]
assert consent_content.type == "oauth_consent_request"
assert consent_content.consent_link == "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc123"
assert consent_content.user_input_request is True


def test_parse_response_with_oauth_consent_output_item(mock_project_client: MagicMock) -> None:
"""Test that a non-streaming oauth_consent_request output item is parsed correctly."""
client = AzureAIClient(project_client=mock_project_client, agent_name="test")

mock_item = MagicMock()
mock_item.type = "oauth_consent_request"
mock_item.consent_link = "https://login.microsoftonline.com/consent?code=abc"

mock_response = MagicMock()
mock_response.output = [mock_item]
mock_response.output_parsed = None
mock_response.metadata = {}
mock_response.id = "resp-oauth-1"
mock_response.model = "test-model"
mock_response.created_at = 1000000000
mock_response.usage = None
mock_response.status = "completed"

response = client._parse_response_from_openai(mock_response, {})

assert len(response.messages) > 0
consent_contents = [c for c in response.messages[0].contents if c.type == "oauth_consent_request"]
assert len(consent_contents) == 1
assert consent_contents[0].consent_link == "https://login.microsoftonline.com/consent?code=abc"


# endregion
36 changes: 36 additions & 0 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ def _serialize_value(value: Any, exclude_none: bool) -> Any:
"mcp_server_tool_result",
"function_approval_request",
"function_approval_response",
"oauth_consent_request",
]


Expand Down Expand Up @@ -468,6 +469,8 @@ def __init__(
function_call: Content | None = None,
user_input_request: bool | None = None,
approved: bool | None = None,
# OAuth consent fields
consent_link: str | None = None,
# Common fields
annotations: Sequence[Annotation] | None = None,
additional_properties: MutableMapping[str, Any] | None = None,
Expand Down Expand Up @@ -508,6 +511,7 @@ def __init__(
self.function_call = function_call
self.user_input_request = user_input_request
self.approved = approved
self.consent_link = consent_link

@classmethod
def from_text(
Expand Down Expand Up @@ -978,6 +982,37 @@ def from_function_approval_response(
raw_representation=raw_representation,
)

@classmethod
def from_oauth_consent_request(
cls: type[ContentT],
consent_link: str,
*,
annotations: Sequence[Annotation] | None = None,
additional_properties: MutableMapping[str, Any] | None = None,
raw_representation: Any = None,
) -> ContentT:
"""Create OAuth consent request content.

Args:
consent_link: The URL the user must visit to complete OAuth consent.

Keyword Args:
annotations: Optional annotations.
additional_properties: Optional additional properties.
raw_representation: Optional raw representation from the provider.

Returns:
A new Content instance with type ``oauth_consent_request``.
"""
return cls(
"oauth_consent_request",
consent_link=consent_link,
user_input_request=True,
annotations=annotations,
additional_properties=additional_properties,
raw_representation=raw_representation,
)

def to_function_approval_response(
self,
approved: bool,
Expand Down Expand Up @@ -1024,6 +1059,7 @@ def to_dict(self, *, exclude_none: bool = True, exclude: set[str] | None = None)
"user_input_request",
"approved",
"id",
"consent_link",
"additional_properties",
)

Expand Down
6 changes: 2 additions & 4 deletions python/packages/core/agent_framework/observability.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from opentelemetry import metrics, trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.semconv.attributes import service_attributes
from opentelemetry.semconv_ai import Meters, SpanAttributes
from opentelemetry.semconv_ai import Meters

from . import __version__ as version_info
from ._settings import load_settings
Expand Down Expand Up @@ -1756,9 +1756,7 @@ def _capture_response(
span.set_attributes(attributes)
attrs: dict[str, Any] = {k: v for k, v in attributes.items() if k in GEN_AI_METRIC_ATTRIBUTES}
if token_usage_histogram and (input_tokens := attributes.get(OtelAttr.INPUT_TOKENS)):
token_usage_histogram.record(
input_tokens, attributes={**attrs, OtelAttr.T_TYPE: OtelAttr.T_TYPE_INPUT}
)
token_usage_histogram.record(input_tokens, attributes={**attrs, OtelAttr.T_TYPE: OtelAttr.T_TYPE_INPUT})
if token_usage_histogram and (output_tokens := attributes.get(OtelAttr.OUTPUT_TOKENS)):
token_usage_histogram.record(output_tokens, {**attrs, OtelAttr.T_TYPE: OtelAttr.T_TYPE_OUTPUT})
if operation_duration_histogram and duration is not None:
Expand Down
8 changes: 4 additions & 4 deletions python/packages/core/agent_framework/openai/_chat_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,9 +411,7 @@ def _parse_response_update_from_openai(
# See https://github.com/microsoft/agent-framework/issues/3434
if chunk.usage:
contents.append(
Content.from_usage(
usage_details=self._parse_usage_from_openai(chunk.usage), raw_representation=chunk
)
Content.from_usage(usage_details=self._parse_usage_from_openai(chunk.usage), raw_representation=chunk)
)

for choice in chunk.choices:
Expand Down Expand Up @@ -591,7 +589,9 @@ def _prepare_message_for_openai(self, message: Message) -> list[dict[str, Any]]:
# See https://github.com/microsoft/agent-framework/issues/4084
for msg in all_messages:
msg_content: Any = msg.get("content")
if isinstance(msg_content, list) and all(isinstance(c, dict) and c.get("type") == "text" for c in msg_content):
if isinstance(msg_content, list) and all(
isinstance(c, dict) and c.get("type") == "text" for c in msg_content
):
msg["content"] = "\n".join(c.get("text", "") for c in msg_content)

return all_messages
Expand Down
27 changes: 27 additions & 0 deletions python/packages/core/tests/core/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -3310,3 +3310,30 @@ def result(r: ChatResponse) -> ChatResponse:


# endregion


# region OAuth Consent Content


def test_oauth_consent_request_creation():
"""Test Content.from_oauth_consent_request creates the correct content."""
content = Content.from_oauth_consent_request(
consent_link="https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc",
)
assert content.type == "oauth_consent_request"
assert content.consent_link == "https://login.microsoftonline.com/common/oauth2/authorize?client_id=abc"
assert content.user_input_request is True


def test_oauth_consent_request_serialization_roundtrip():
"""Test that oauth_consent_request content serializes and includes consent_link."""
content = Content.from_oauth_consent_request(
consent_link="https://login.microsoftonline.com/consent",
)
d = content.to_dict()
assert d["type"] == "oauth_consent_request"
assert d["consent_link"] == "https://login.microsoftonline.com/consent"
assert d["user_input_request"] is True


# endregion