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,
|
||||
)
|
||||
@@ -10,7 +10,17 @@ from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentResponse, AgentSession, ChatContext, ChatMiddleware, ChatResponse, Message, tool
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
AgentSession,
|
||||
ChatContext,
|
||||
ChatMiddleware,
|
||||
ChatResponse,
|
||||
ChatResponseUpdate,
|
||||
Message,
|
||||
tool,
|
||||
)
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
@@ -287,6 +297,31 @@ def test_raw_foundry_agent_chat_client_parse_response_suppresses_conversation_id
|
||||
assert result.conversation_id is None
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_parse_chunk_suppresses_conversation_id_for_agent_sessions() -> None:
|
||||
"""Test that agent-session stream updates do not overwrite session.service_session_id."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
parsed = ChatResponseUpdate(conversation_id="resp_123")
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._parse_chunk_from_openai",
|
||||
return_value=parsed,
|
||||
):
|
||||
result = client._parse_chunk_from_openai(
|
||||
event=MagicMock(type="response.output_text.delta"),
|
||||
options={"conversation_id": "agent-session-123"},
|
||||
function_call_ids={},
|
||||
)
|
||||
|
||||
assert result.conversation_id is None
|
||||
|
||||
|
||||
def test_raw_foundry_agent_chat_client_check_model_presence_is_noop() -> None:
|
||||
"""Test that _check_model_presence does nothing (model is on service)."""
|
||||
|
||||
@@ -622,3 +657,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"
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ from agent_framework import ChatResponse, Content, Message, SupportsChatGetRespo
|
||||
from agent_framework._telemetry import get_user_agent
|
||||
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
|
||||
from agent_framework_openai import OpenAIContentFilterException
|
||||
from agent_framework_openai._chat_client import RawOpenAIChatClient
|
||||
from azure.ai.projects.models import MCPTool as FoundryMCPTool
|
||||
from azure.core.exceptions import ResourceNotFoundError
|
||||
from azure.identity import AzureCliCredential
|
||||
@@ -993,3 +994,165 @@ def test_get_mcp_tool_with_connection_id() -> None:
|
||||
description="GitHub MCP via Foundry",
|
||||
)
|
||||
assert tool_obj is not None
|
||||
|
||||
|
||||
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_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
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"
|
||||
assert update.role == "assistant"
|
||||
assert update.raw_representation is mock_event
|
||||
assert update.model == "test-model"
|
||||
|
||||
|
||||
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_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
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_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
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_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
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_openai = _make_mock_openai_client()
|
||||
mock_project.get_openai_client.return_value = mock_openai
|
||||
|
||||
client = RawFoundryChatClient(
|
||||
project_client=mock_project,
|
||||
model="test-model",
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_foundry._oauth_helpers import _validate_consent_link, try_parse_oauth_consent_event
|
||||
|
||||
# region _validate_consent_link tests
|
||||
|
||||
|
||||
def test_validate_consent_link_accepts_valid_https() -> None:
|
||||
"""A valid HTTPS URL with a netloc passes validation."""
|
||||
link = "https://consent.example.com/auth?code=123"
|
||||
assert _validate_consent_link(link, "item-1") == link
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_http(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTP link is rejected and a warning is logged."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("http://insecure.example.com/login", "item-2")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-2" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with an empty netloc (e.g. https:///path) is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("https:///path", "item-3")
|
||||
assert result == ""
|
||||
assert "non-HTTPS" in caplog.text
|
||||
assert "item-3" in caplog.text
|
||||
|
||||
|
||||
def test_validate_consent_link_rejects_non_url(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-URL string is rejected."""
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result = _validate_consent_link("not-a-url", "item-4")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region try_parse_oauth_consent_event tests
|
||||
|
||||
|
||||
def _make_output_item_event(
|
||||
*,
|
||||
item_type: str = "oauth_consent_request",
|
||||
consent_link: Any = "https://consent.example.com/auth",
|
||||
item_id: str = "oauth-item-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.output_item.added`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_item.added"
|
||||
item = MagicMock()
|
||||
item.type = item_type
|
||||
item.consent_link = consent_link
|
||||
item.id = item_id
|
||||
event.item = item
|
||||
return event
|
||||
|
||||
|
||||
def _make_top_level_event(
|
||||
*,
|
||||
consent_link: Any = "https://consent.example.com/authorize",
|
||||
event_id: str = "consent-event-1",
|
||||
) -> MagicMock:
|
||||
"""Create a mock ``response.oauth_consent_requested`` event."""
|
||||
event = MagicMock()
|
||||
event.type = "response.oauth_consent_requested"
|
||||
event.consent_link = consent_link
|
||||
event.id = event_id
|
||||
return event
|
||||
|
||||
|
||||
def test_returns_none_for_unrelated_event() -> None:
|
||||
"""An event with a non-oauth type returns None."""
|
||||
event = MagicMock()
|
||||
event.type = "response.output_text.delta"
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_returns_none_for_event_without_type() -> None:
|
||||
"""An event object missing a 'type' attribute returns None."""
|
||||
event = object() # no type attribute
|
||||
assert try_parse_oauth_consent_event(event, "model-x") is None
|
||||
|
||||
|
||||
def test_parses_output_item_added_with_valid_link() -> None:
|
||||
"""A response.output_item.added event with a valid HTTPS link produces Content."""
|
||||
event = _make_output_item_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert update.role == "assistant"
|
||||
assert update.model == "test-model"
|
||||
assert update.raw_representation is event
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/auth"
|
||||
|
||||
|
||||
def test_parses_top_level_consent_requested_event() -> None:
|
||||
"""A response.oauth_consent_requested event produces Content."""
|
||||
event = _make_top_level_event()
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
consent = [c for c in update.contents if c.type == "oauth_consent_request"]
|
||||
assert len(consent) == 1
|
||||
assert consent[0].consent_link == "https://consent.example.com/authorize"
|
||||
|
||||
|
||||
def test_empty_contents_for_non_https_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A non-HTTPS consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="http://bad.example.com/login", item_id="item-http")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_missing_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A None consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link=None, item_id="item-none")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_empty_string_consent_link(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An empty-string consent_link produces an update with empty contents and logs a warning."""
|
||||
event = _make_output_item_event(consent_link="", item_id="item-empty")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "without valid consent_link" in caplog.text
|
||||
|
||||
|
||||
def test_empty_contents_for_https_empty_netloc(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""An HTTPS URL with empty netloc (https:///path) is rejected."""
|
||||
event = _make_output_item_event(consent_link="https:///path", item_id="item-no-netloc")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
update = try_parse_oauth_consent_event(event, "test-model")
|
||||
|
||||
assert update is not None
|
||||
assert len(update.contents) == 0
|
||||
assert "non-HTTPS" in caplog.text
|
||||
|
||||
|
||||
# endregion
|
||||
Reference in New Issue
Block a user