diff --git a/python/packages/foundry/agent_framework_foundry/_agent.py b/python/packages/foundry/agent_framework_foundry/_agent.py index da64b65cd9..fbf165ab21 100644 --- a/python/packages/foundry/agent_framework_foundry/_agent.py +++ b/python/packages/foundry/agent_framework_foundry/_agent.py @@ -19,6 +19,7 @@ AgentSession, ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer, + ChatResponseUpdate, ContextProvider, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -35,6 +36,8 @@ 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 @@ def _parse_chunk_from_openai( 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: diff --git a/python/packages/foundry/agent_framework_foundry/_chat_client.py b/python/packages/foundry/agent_framework_foundry/_chat_client.py index 57522fb886..fc2b29e1e4 100644 --- a/python/packages/foundry/agent_framework_foundry/_chat_client.py +++ b/python/packages/foundry/agent_framework_foundry/_chat_client.py @@ -9,6 +9,7 @@ from agent_framework import ( ChatMiddlewareLayer, + ChatResponseUpdate, Content, FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -33,6 +34,8 @@ 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 @@ def _prepare_tools_for_openai( 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, diff --git a/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py new file mode 100644 index 0000000000..873d42c3d9 --- /dev/null +++ b/python/packages/foundry/agent_framework_foundry/_oauth_helpers.py @@ -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", "") + + 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, + ) diff --git a/python/packages/foundry/tests/foundry/test_foundry_agent.py b/python/packages/foundry/tests/foundry/test_foundry_agent.py index 73670d0bbc..e110e540fe 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_agent.py +++ b/python/packages/foundry/tests/foundry/test_foundry_agent.py @@ -10,7 +10,17 @@ 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 diff --git a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py index a7b57029ba..2ef5ca04ee 100644 --- a/python/packages/foundry/tests/foundry/test_foundry_chat_client.py +++ b/python/packages/foundry/tests/foundry/test_foundry_chat_client.py @@ -15,6 +15,7 @@ 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 diff --git a/python/packages/foundry/tests/foundry/test_oauth_helpers.py b/python/packages/foundry/tests/foundry/test_oauth_helpers.py new file mode 100644 index 0000000000..2ab209e141 --- /dev/null +++ b/python/packages/foundry/tests/foundry/test_oauth_helpers.py @@ -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