mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Surface oauth_consent_request events from Responses API in Foundry clients (#5070)
* 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> * 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> * 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> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * 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> * Address review feedback for #5054: Python: [Bug]: `FoundryAgent` (Responses API) Does Not Surface `oauth_consent_request` as a CUSTOM AG-UI Event * 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> --------- Co-authored-by: Copilot <copilot@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
da32e8cf80
commit
7b70f80036
@@ -19,6 +19,7 @@ from agent_framework import (
|
||||
AgentSession,
|
||||
ChatAndFunctionMiddlewareTypes,
|
||||
ChatMiddlewareLayer,
|
||||
ChatResponseUpdate,
|
||||
ContextProvider,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
@@ -35,6 +36,8 @@ from azure.ai.projects.aio import AIProjectClient
|
||||
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 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
@@ -373,16 +376,19 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
options: dict[str, Any],
|
||||
function_call_ids: dict[int, tuple[str, str]],
|
||||
seen_reasoning_delta_item_ids: set[str] | None = None,
|
||||
) -> Any:
|
||||
parsed_chunk = super()._parse_chunk_from_openai(
|
||||
event,
|
||||
options,
|
||||
function_call_ids,
|
||||
seen_reasoning_delta_item_ids,
|
||||
)
|
||||
) -> ChatResponseUpdate:
|
||||
"""Parse streaming events while preserving hosted-agent session state."""
|
||||
update = try_parse_oauth_consent_event(event, self.model)
|
||||
if update is None:
|
||||
update = super()._parse_chunk_from_openai(
|
||||
event,
|
||||
options,
|
||||
function_call_ids,
|
||||
seen_reasoning_delta_item_ids,
|
||||
)
|
||||
if _uses_foundry_agent_session(options.get("conversation_id")):
|
||||
parsed_chunk.conversation_id = None
|
||||
return parsed_chunk
|
||||
update.conversation_id = None
|
||||
return update
|
||||
|
||||
@override
|
||||
def _check_model_presence(self, options: dict[str, Any]) -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
|
||||
|
||||
from agent_framework import (
|
||||
ChatMiddlewareLayer,
|
||||
ChatResponseUpdate,
|
||||
Content,
|
||||
FunctionInvocationConfiguration,
|
||||
FunctionInvocationLayer,
|
||||
@@ -33,6 +34,8 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
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, fetch_toolbox # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
if sys.version_info >= (3, 13):
|
||||
@@ -241,6 +244,20 @@ class RawFoundryChatClient( # type: ignore[misc]
|
||||
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)
|
||||
|
||||
async def configure_azure_monitor(
|
||||
self,
|
||||
enable_sensitive_data: bool = False,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user