diff --git a/openhands/app_server/app_conversation/live_status_app_conversation_service.py b/openhands/app_server/app_conversation/live_status_app_conversation_service.py index 9b290455c6..2f04bf9a71 100644 --- a/openhands/app_server/app_conversation/live_status_app_conversation_service.py +++ b/openhands/app_server/app_conversation/live_status_app_conversation_service.py @@ -102,6 +102,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase): sandbox_startup_poll_frequency: int httpx_client: httpx.AsyncClient web_url: str | None + openhands_provider_base_url: str | None access_token_hard_timeout: timedelta | None app_mode: str | None = None keycloak_auth_cookie: str | None = None @@ -590,9 +591,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase): """ # Configure LLM model = llm_model or user.llm_model + base_url = user.llm_base_url + if model and model.startswith('openhands/'): + base_url = user.llm_base_url or self.openhands_provider_base_url llm = LLM( model=model, - base_url=user.llm_base_url, + base_url=base_url, api_key=user.llm_api_key, usage_id='agent', ) @@ -1082,6 +1086,7 @@ class LiveStatusAppConversationServiceInjector(AppConversationServiceInjector): sandbox_startup_poll_frequency=self.sandbox_startup_poll_frequency, httpx_client=httpx_client, web_url=web_url, + openhands_provider_base_url=config.openhands_provider_base_url, access_token_hard_timeout=access_token_hard_timeout, app_mode=app_mode, keycloak_auth_cookie=keycloak_auth_cookie, diff --git a/openhands/app_server/config.py b/openhands/app_server/config.py index b44608a887..3c40806af0 100644 --- a/openhands/app_server/config.py +++ b/openhands/app_server/config.py @@ -74,6 +74,11 @@ def get_default_web_url() -> str | None: return f'https://{web_host}' +def get_openhands_provider_base_url() -> str | None: + """Return the base URL for the OpenHands provider, if configured.""" + return os.getenv('OPENHANDS_PROVIDER_BASE_URL') or None + + def _get_default_lifespan(): # Check legacy parameters for saas mode. If we are in SAAS mode do not apply # OSS alembic migrations @@ -88,6 +93,10 @@ class AppServerConfig(OpenHandsModel): default_factory=get_default_web_url, description='The URL where OpenHands is running (e.g., http://localhost:3000)', ) + openhands_provider_base_url: str | None = Field( + default_factory=get_openhands_provider_base_url, + description='Base URL for the OpenHands provider', + ) # Dependency Injection Injectors event: EventServiceInjector | None = None event_callback: EventCallbackServiceInjector | None = None diff --git a/tests/unit/app_server/test_live_status_app_conversation_service.py b/tests/unit/app_server/test_live_status_app_conversation_service.py index 62cd0858f3..1dabdfa88a 100644 --- a/tests/unit/app_server/test_live_status_app_conversation_service.py +++ b/tests/unit/app_server/test_live_status_app_conversation_service.py @@ -52,6 +52,7 @@ class TestLiveStatusAppConversationService: sandbox_startup_poll_frequency=1, httpx_client=self.mock_httpx_client, web_url='https://test.example.com', + openhands_provider_base_url='https://provider.example.com', access_token_hard_timeout=None, app_mode='test', keycloak_auth_cookie=None, @@ -66,6 +67,7 @@ class TestLiveStatusAppConversationService: self.mock_user.confirmation_mode = False self.mock_user.search_api_key = None # Default to None self.mock_user.condenser_max_size = None # Default to None + self.mock_user.llm_base_url = 'https://api.openai.com/v1' # Mock sandbox self.mock_sandbox = Mock(spec=SandboxInfo) @@ -241,6 +243,70 @@ class TestLiveStatusAppConversationService: assert mcp_config['default']['url'] == 'https://test.example.com/mcp/mcp' assert mcp_config['default']['headers']['X-Session-API-Key'] == 'mcp_api_key' + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_openhands_model_prefers_user_base_url(self): + """openhands/* model uses user.llm_base_url when provided.""" + # Arrange + self.mock_user.llm_model = 'openhands/special' + self.mock_user.llm_base_url = 'https://user-llm.example.com' + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp( + self.mock_user, self.mock_user.llm_model + ) + + # Assert + assert llm.base_url == 'https://user-llm.example.com' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_openhands_model_uses_provider_default(self): + """openhands/* model falls back to configured provider base URL.""" + # Arrange + self.mock_user.llm_model = 'openhands/default' + self.mock_user.llm_base_url = None + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp( + self.mock_user, self.mock_user.llm_model + ) + + # Assert + assert llm.base_url == 'https://provider.example.com' + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_openhands_model_no_base_urls(self): + """openhands/* model sets base_url to None when no sources available.""" + # Arrange + self.mock_user.llm_model = 'openhands/default' + self.mock_user.llm_base_url = None + self.service.openhands_provider_base_url = None + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp( + self.mock_user, self.mock_user.llm_model + ) + + # Assert + assert llm.base_url is None + + @pytest.mark.asyncio + async def test_configure_llm_and_mcp_non_openhands_model_ignores_provider(self): + """Non-openhands model ignores provider base URL and uses user base URL.""" + # Arrange + self.mock_user.llm_model = 'gpt-4' + self.mock_user.llm_base_url = 'https://user-llm.example.com' + self.service.openhands_provider_base_url = 'https://provider.example.com' + self.mock_user_context.get_mcp_api_key.return_value = None + + # Act + llm, _ = await self.service._configure_llm_and_mcp(self.mock_user, None) + + # Assert + assert llm.base_url == 'https://user-llm.example.com' + @pytest.mark.asyncio async def test_configure_llm_and_mcp_with_user_default_model(self): """Test _configure_llm_and_mcp using user's default model.""" diff --git a/tests/unit/experiments/test_experiment_manager.py b/tests/unit/experiments/test_experiment_manager.py index f726c1eca3..85faa078f5 100644 --- a/tests/unit/experiments/test_experiment_manager.py +++ b/tests/unit/experiments/test_experiment_manager.py @@ -188,6 +188,7 @@ class TestExperimentManagerIntegration: sandbox_startup_poll_frequency=1, httpx_client=httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, ) diff --git a/tests/unit/server/data_models/test_conversation.py b/tests/unit/server/data_models/test_conversation.py index cf12e83361..79ff91fa7f 100644 --- a/tests/unit/server/data_models/test_conversation.py +++ b/tests/unit/server/data_models/test_conversation.py @@ -2166,6 +2166,7 @@ async def test_delete_v1_conversation_with_sub_conversations(): sandbox_startup_poll_frequency=2, httpx_client=mock_httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, ) @@ -2287,6 +2288,7 @@ async def test_delete_v1_conversation_with_no_sub_conversations(): sandbox_startup_poll_frequency=2, httpx_client=mock_httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, ) @@ -2438,6 +2440,7 @@ async def test_delete_v1_conversation_sub_conversation_deletion_error(): sandbox_startup_poll_frequency=2, httpx_client=mock_httpx_client, web_url=None, + openhands_provider_base_url=None, access_token_hard_timeout=None, )