diff --git a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py index cec86bdcf3..11f71650fd 100644 --- a/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py +++ b/python/packages/ag-ui/agent_framework_ag_ui/_run_common.py @@ -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, @@ -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 [] diff --git a/python/packages/ag-ui/tests/ag_ui/test_run.py b/python/packages/ag-ui/tests/ag_ui/test_run.py index 4abd63c799..c54f0ab433 100644 --- a/python/packages/ag-ui/tests/ag_ui/test_run.py +++ b/python/packages/ag-ui/tests/ag_ui/test_run.py @@ -4,6 +4,7 @@ import pytest from ag_ui.core import ( + CustomEvent, TextMessageEndEvent, TextMessageStartEvent, ) @@ -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 diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index de9df819ec..b83c0fb8b9 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -15,6 +15,9 @@ BaseContextProvider, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ChatResponse, + ChatResponseUpdate, + Content, FunctionInvocationConfiguration, FunctionInvocationLayer, FunctionTool, @@ -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: + 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": + 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] = [] diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index 5824b668f1..37d625e5dc 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -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 diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index c664fd3446..02b9e54371 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -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", ] @@ -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, @@ -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( @@ -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, @@ -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", ) diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index 338e4c6ab7..f9178c1207 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -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 @@ -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: diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index 5d6f66491c..f08d80e990 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -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: @@ -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 diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index 8a8885b919..78e40806ca 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -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