From 30dca52f556a19dcd1bf624288c0b3d2d1cef7c4 Mon Sep 17 00:00:00 2001 From: seymourtang Date: Mon, 3 Aug 2026 14:24:30 +0800 Subject: [PATCH 1/2] feat: add internal fixed base URL support to Pool class - Introduced functionality to use an internal fixed base URL for API requests based on the AGORA_AGENTS_INTERNAL_API_BASE_URL environment variable. - Updated the Pool class to disable dynamic domain selection when a fixed base URL is set. - Added tests to verify the correct behavior of the new base URL feature and its impact on domain selection. --- src/agora_agent/core/domain.py | 40 +++++++++++- tests/custom/test_domain.py | 115 +++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 tests/custom/test_domain.py diff --git a/src/agora_agent/core/domain.py b/src/agora_agent/core/domain.py index 753dbfb..f18f87c 100644 --- a/src/agora_agent/core/domain.py +++ b/src/agora_agent/core/domain.py @@ -1,9 +1,12 @@ # This file was auto-generated by Fern from our API Definition. import asyncio +import os +import posixpath import socket import threading import time +import urllib.parse from enum import IntEnum from typing import List, Optional @@ -38,6 +41,27 @@ class Area(IntEnum): GLOBAL_API_PATH_SUFFIX = "/api/conversational-ai-agent" CN_API_PATH_SUFFIX = "/cn/api/conversational-ai-agent" +_INTERNAL_API_BASE_URL_ENV = "AGORA_AGENTS_INTERNAL_API_BASE_URL" + + +def _convo_ai_api_path_suffix(area: Area) -> str: + if area == Area.CN: + return CN_API_PATH_SUFFIX + return GLOBAL_API_PATH_SUFFIX + + +def _internal_fixed_base_url(area: Area) -> Optional[str]: + raw_base_url = os.getenv(_INTERNAL_API_BASE_URL_ENV, "").strip() + if not raw_base_url: + return None + + try: + parsed_url = urllib.parse.urlsplit(raw_base_url) + path = posixpath.join(parsed_url.path, _convo_ai_api_path_suffix(area).lstrip("/")) + return parsed_url._replace(path=path).geturl() + except ValueError as exc: + raise ValueError(f"invalid {_INTERNAL_API_BASE_URL_ENV}: {exc}") from exc + class Domain: """Domain contains the regional prefixes and domain suffixes for an area""" @@ -142,6 +166,7 @@ def __init__(self, domain_area: Area): raise ValueError("invalid domain area") self._domain_area = domain_area + self._fixed_base_url = _internal_fixed_base_url(domain_area) self._domain_suffixes = list(domain_config.major_domain_suffixes) self._region_prefixes = list(domain_config.region_domain_prefixes) self._current_region_prefixes = list(self._region_prefixes) @@ -156,6 +181,9 @@ def _domain_need_update(self) -> bool: def select_best_domain(self) -> None: """SelectBestDomain uses DNS resolution to select the best available domain (sync)""" + if self._fixed_base_url is not None: + return + if not self._domain_need_update(): return @@ -166,6 +194,9 @@ def select_best_domain(self) -> None: async def select_best_domain_async(self) -> None: """SelectBestDomain uses DNS resolution to select the best available domain (async)""" + if self._fixed_base_url is not None: + return + if not self._domain_need_update(): return @@ -178,6 +209,9 @@ async def select_best_domain_async(self) -> None: def next_region(self) -> None: """NextRegion cycles to the next region prefix in the pool""" + if self._fixed_base_url is not None: + return + with self._lock: self._current_region_prefixes = self._current_region_prefixes[1:] if len(self._current_region_prefixes) == 0: @@ -191,10 +225,12 @@ def _select_domain(self, domain: str) -> None: def get_current_url(self) -> str: """GetCurrentURL returns the current URL based on the selected region and domain""" with self._lock: + if self._fixed_base_url is not None: + return self._fixed_base_url + current_region = self._current_region_prefixes[0] current_domain = self._current_domain - path_suffix = CN_API_PATH_SUFFIX if self._domain_area == Area.CN else GLOBAL_API_PATH_SUFFIX - return f"https://{current_region}.{current_domain}{path_suffix}" + return f"https://{current_region}.{current_domain}{_convo_ai_api_path_suffix(self._domain_area)}" def get_area(self) -> Area: """Get the current area""" diff --git a/tests/custom/test_domain.py b/tests/custom/test_domain.py new file mode 100644 index 0000000..c30bacb --- /dev/null +++ b/tests/custom/test_domain.py @@ -0,0 +1,115 @@ +from typing import List + +import httpx +import pytest + +from agora_agent import Agora +from agora_agent.core.domain import Area, AsyncResolverImpl, Pool, ResolverImpl + +_INTERNAL_API_BASE_URL_ENV = "AGORA_AGENTS_INTERNAL_API_BASE_URL" + + +@pytest.mark.parametrize( + ("area", "expected"), + [ + (Area.US, "https://api-test.agora.io/api/conversational-ai-agent"), + (Area.EU, "https://api-test.agora.io/api/conversational-ai-agent"), + (Area.AP, "https://api-test.agora.io/api/conversational-ai-agent"), + (Area.CN, "https://api-test.agora.io/cn/api/conversational-ai-agent"), + ], +) +def test_pool_uses_internal_fixed_base_url( + monkeypatch: pytest.MonkeyPatch, + area: Area, + expected: str, +) -> None: + monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io/") + + assert Pool(area).get_current_url() == expected + + +@pytest.mark.parametrize( + ("base_url", "expected"), + [ + ( + "https://staging.example.com/", + "https://staging.example.com/api/conversational-ai-agent", + ), + ( + "http://localhost:8080", + "http://localhost:8080/api/conversational-ai-agent", + ), + ( + "https://user:password@staging.example.com/gateway?debug=true#section", + "https://user:password@staging.example.com/gateway/api/conversational-ai-agent?debug=true#section", + ), + ], +) +def test_pool_accepts_configured_base_url( + monkeypatch: pytest.MonkeyPatch, + base_url: str, + expected: str, +) -> None: + monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, base_url) + + assert Pool(Area.US).get_current_url() == expected + + +def test_fixed_base_url_disables_dynamic_routing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io") + pool = Pool(Area.US) + pool._resolver = _FailingResolver() + + expected = "https://api-test.agora.io/api/conversational-ai-agent" + for _ in range(3): + pool.next_region() + pool.select_best_domain() + assert pool.get_current_url() == expected + + +@pytest.mark.asyncio +async def test_fixed_base_url_disables_async_domain_selection(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io") + pool = Pool(Area.CN) + pool._async_resolver = _FailingAsyncResolver() + + await pool.select_best_domain_async() + + assert pool.get_current_url() == "https://api-test.agora.io/cn/api/conversational-ai-agent" + + +def test_pool_without_internal_base_url_keeps_regional_routing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(_INTERNAL_API_BASE_URL_ENV, raising=False) + pool = Pool(Area.US) + + assert pool.get_current_url() == "https://api-us-west-1.agora.io/api/conversational-ai-agent" + + pool.next_region() + + assert pool.get_current_url() == "https://api-us-east-1.agora.io/api/conversational-ai-agent" + + +def test_client_uses_internal_fixed_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io") + + with httpx.Client() as httpx_client: + client = Agora( + area=Area.CN, + app_id="0" * 32, + app_certificate="1" * 32, + httpx_client=httpx_client, + ) + + expected = "https://api-test.agora.io/cn/api/conversational-ai-agent" + assert client.get_current_url() == expected + assert client._client_wrapper.get_base_url() == expected + + +class _FailingResolver(ResolverImpl): + def resolve(self, domains: List[str], region_prefix: str) -> str: + raise AssertionError("resolver should not be called") + + +class _FailingAsyncResolver(AsyncResolverImpl): + async def resolve_async(self, domains: List[str], region_prefix: str) -> str: + raise AssertionError("async resolver should not be called") From 0cbfab32efbe8bac7262d14145a81a3478447f57 Mon Sep 17 00:00:00 2001 From: seymourtang Date: Mon, 3 Aug 2026 15:40:53 +0800 Subject: [PATCH 2/2] refactor: rename internal API base URL to configured base URL - Updated the environment variable from AGORA_AGENTS_INTERNAL_API_BASE_URL to AGORA_AGENTS_API_BASE_URL for clarity. - Refactored related functions and tests to reflect the new naming convention. - Ensured that the Pool class and its methods utilize the updated base URL for domain selection and routing. --- src/agora_agent/core/domain.py | 20 ++++++++++---------- tests/custom/test_domain.py | 24 ++++++++++++------------ 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/agora_agent/core/domain.py b/src/agora_agent/core/domain.py index f18f87c..225dec8 100644 --- a/src/agora_agent/core/domain.py +++ b/src/agora_agent/core/domain.py @@ -41,7 +41,7 @@ class Area(IntEnum): GLOBAL_API_PATH_SUFFIX = "/api/conversational-ai-agent" CN_API_PATH_SUFFIX = "/cn/api/conversational-ai-agent" -_INTERNAL_API_BASE_URL_ENV = "AGORA_AGENTS_INTERNAL_API_BASE_URL" +_API_BASE_URL_ENV = "AGORA_AGENTS_API_BASE_URL" def _convo_ai_api_path_suffix(area: Area) -> str: @@ -50,8 +50,8 @@ def _convo_ai_api_path_suffix(area: Area) -> str: return GLOBAL_API_PATH_SUFFIX -def _internal_fixed_base_url(area: Area) -> Optional[str]: - raw_base_url = os.getenv(_INTERNAL_API_BASE_URL_ENV, "").strip() +def _get_configured_base_url(area: Area) -> Optional[str]: + raw_base_url = os.getenv(_API_BASE_URL_ENV, "").strip() if not raw_base_url: return None @@ -60,7 +60,7 @@ def _internal_fixed_base_url(area: Area) -> Optional[str]: path = posixpath.join(parsed_url.path, _convo_ai_api_path_suffix(area).lstrip("/")) return parsed_url._replace(path=path).geturl() except ValueError as exc: - raise ValueError(f"invalid {_INTERNAL_API_BASE_URL_ENV}: {exc}") from exc + raise ValueError(f"invalid {_API_BASE_URL_ENV}: {exc}") from exc class Domain: @@ -166,7 +166,7 @@ def __init__(self, domain_area: Area): raise ValueError("invalid domain area") self._domain_area = domain_area - self._fixed_base_url = _internal_fixed_base_url(domain_area) + self._configured_base_url = _get_configured_base_url(domain_area) self._domain_suffixes = list(domain_config.major_domain_suffixes) self._region_prefixes = list(domain_config.region_domain_prefixes) self._current_region_prefixes = list(self._region_prefixes) @@ -181,7 +181,7 @@ def _domain_need_update(self) -> bool: def select_best_domain(self) -> None: """SelectBestDomain uses DNS resolution to select the best available domain (sync)""" - if self._fixed_base_url is not None: + if self._configured_base_url is not None: return if not self._domain_need_update(): @@ -194,7 +194,7 @@ def select_best_domain(self) -> None: async def select_best_domain_async(self) -> None: """SelectBestDomain uses DNS resolution to select the best available domain (async)""" - if self._fixed_base_url is not None: + if self._configured_base_url is not None: return if not self._domain_need_update(): @@ -209,7 +209,7 @@ async def select_best_domain_async(self) -> None: def next_region(self) -> None: """NextRegion cycles to the next region prefix in the pool""" - if self._fixed_base_url is not None: + if self._configured_base_url is not None: return with self._lock: @@ -225,8 +225,8 @@ def _select_domain(self, domain: str) -> None: def get_current_url(self) -> str: """GetCurrentURL returns the current URL based on the selected region and domain""" with self._lock: - if self._fixed_base_url is not None: - return self._fixed_base_url + if self._configured_base_url is not None: + return self._configured_base_url current_region = self._current_region_prefixes[0] current_domain = self._current_domain diff --git a/tests/custom/test_domain.py b/tests/custom/test_domain.py index c30bacb..5ad9966 100644 --- a/tests/custom/test_domain.py +++ b/tests/custom/test_domain.py @@ -6,7 +6,7 @@ from agora_agent import Agora from agora_agent.core.domain import Area, AsyncResolverImpl, Pool, ResolverImpl -_INTERNAL_API_BASE_URL_ENV = "AGORA_AGENTS_INTERNAL_API_BASE_URL" +_API_BASE_URL_ENV = "AGORA_AGENTS_API_BASE_URL" @pytest.mark.parametrize( @@ -18,12 +18,12 @@ (Area.CN, "https://api-test.agora.io/cn/api/conversational-ai-agent"), ], ) -def test_pool_uses_internal_fixed_base_url( +def test_pool_uses_configured_base_url( monkeypatch: pytest.MonkeyPatch, area: Area, expected: str, ) -> None: - monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io/") + monkeypatch.setenv(_API_BASE_URL_ENV, "https://api-test.agora.io/") assert Pool(area).get_current_url() == expected @@ -50,13 +50,13 @@ def test_pool_accepts_configured_base_url( base_url: str, expected: str, ) -> None: - monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, base_url) + monkeypatch.setenv(_API_BASE_URL_ENV, base_url) assert Pool(Area.US).get_current_url() == expected -def test_fixed_base_url_disables_dynamic_routing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io") +def test_configured_base_url_disables_dynamic_routing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_API_BASE_URL_ENV, "https://api-test.agora.io") pool = Pool(Area.US) pool._resolver = _FailingResolver() @@ -68,8 +68,8 @@ def test_fixed_base_url_disables_dynamic_routing(monkeypatch: pytest.MonkeyPatch @pytest.mark.asyncio -async def test_fixed_base_url_disables_async_domain_selection(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io") +async def test_configured_base_url_disables_async_domain_selection(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_API_BASE_URL_ENV, "https://api-test.agora.io") pool = Pool(Area.CN) pool._async_resolver = _FailingAsyncResolver() @@ -78,8 +78,8 @@ async def test_fixed_base_url_disables_async_domain_selection(monkeypatch: pytes assert pool.get_current_url() == "https://api-test.agora.io/cn/api/conversational-ai-agent" -def test_pool_without_internal_base_url_keeps_regional_routing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(_INTERNAL_API_BASE_URL_ENV, raising=False) +def test_pool_without_configured_base_url_keeps_regional_routing(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(_API_BASE_URL_ENV, raising=False) pool = Pool(Area.US) assert pool.get_current_url() == "https://api-us-west-1.agora.io/api/conversational-ai-agent" @@ -89,8 +89,8 @@ def test_pool_without_internal_base_url_keeps_regional_routing(monkeypatch: pyte assert pool.get_current_url() == "https://api-us-east-1.agora.io/api/conversational-ai-agent" -def test_client_uses_internal_fixed_base_url(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(_INTERNAL_API_BASE_URL_ENV, "https://api-test.agora.io") +def test_client_uses_configured_base_url(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_API_BASE_URL_ENV, "https://api-test.agora.io") with httpx.Client() as httpx_client: client = Agora(