mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
327 lines
11 KiB
Python
327 lines
11 KiB
Python
import os
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import Request
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import SecretStr
|
|
|
|
from openhands.app_server.secrets.secrets_store import SecretsStore
|
|
from openhands.app_server.settings.file_settings_store import FileSettingsStore
|
|
from openhands.app_server.settings.settings_store import SettingsStore
|
|
from openhands.integrations.provider import ProviderToken, ProviderType
|
|
from openhands.integrations.service_types import UserGitInfo
|
|
from openhands.sdk.llm import LLM
|
|
from openhands.sdk.settings import (
|
|
AgentSettings,
|
|
ConversationSettings,
|
|
VerificationSettings,
|
|
)
|
|
from openhands.server.app import app
|
|
from openhands.server.user_auth.user_auth import UserAuth
|
|
from openhands.storage.data_models.secrets import Secrets
|
|
from openhands.storage.data_models.settings import Settings
|
|
from openhands.storage.memory import InMemoryFileStore
|
|
|
|
_EXPOSE = {'expose_secrets': True}
|
|
|
|
|
|
def _dump(settings: Settings) -> dict:
|
|
"""Dump settings to a JSON-compatible dict with secrets exposed."""
|
|
return settings.model_dump(mode='json', context=_EXPOSE, exclude_unset=True)
|
|
|
|
|
|
def _dump_update(settings: Settings) -> dict:
|
|
"""Dump a settings update payload using diff-only nested keys."""
|
|
payload = _dump(settings)
|
|
if 'agent_settings' in payload:
|
|
payload['agent_settings_diff'] = payload.pop('agent_settings')
|
|
if 'conversation_settings' in payload:
|
|
payload['conversation_settings_diff'] = payload.pop('conversation_settings')
|
|
return payload
|
|
|
|
|
|
class MockUserAuth(UserAuth):
|
|
"""Mock implementation of UserAuth for testing."""
|
|
|
|
def __init__(self):
|
|
self._settings = None
|
|
self._settings_store = MagicMock()
|
|
self._settings_store.load = AsyncMock(return_value=None)
|
|
self._settings_store.store = AsyncMock()
|
|
|
|
async def get_user_id(self) -> str | None:
|
|
return 'test-user'
|
|
|
|
async def get_user_email(self) -> str | None:
|
|
return 'test-email@whatever.com'
|
|
|
|
async def get_access_token(self) -> SecretStr | None:
|
|
return SecretStr('test-token')
|
|
|
|
async def get_provider_tokens(
|
|
self,
|
|
) -> dict[ProviderType, ProviderToken] | None: # noqa: E501
|
|
return None
|
|
|
|
async def get_user_settings_store(self) -> SettingsStore | None:
|
|
return self._settings_store
|
|
|
|
async def get_secrets_store(self) -> SecretsStore | None:
|
|
return None
|
|
|
|
async def get_secrets(self) -> Secrets | None:
|
|
return None
|
|
|
|
async def get_mcp_api_key(self) -> str | None:
|
|
return None
|
|
|
|
async def get_user_git_info(self) -> UserGitInfo | None:
|
|
return None
|
|
|
|
@classmethod
|
|
async def get_instance(cls, request: Request) -> UserAuth:
|
|
return MockUserAuth()
|
|
|
|
@classmethod
|
|
async def get_for_user(cls, user_id: str) -> UserAuth:
|
|
return MockUserAuth()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_client():
|
|
# Create a test client
|
|
with (
|
|
patch.dict(
|
|
os.environ,
|
|
{'SESSION_API_KEY': '', 'ALLOW_SHORT_CONTEXT_WINDOWS': 'true'},
|
|
clear=False,
|
|
),
|
|
patch('openhands.app_server.utils.dependencies._SESSION_API_KEY', None),
|
|
patch(
|
|
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
|
return_value=MockUserAuth(),
|
|
),
|
|
patch(
|
|
'openhands.app_server.settings.file_settings_store.FileSettingsStore.get_instance',
|
|
AsyncMock(return_value=FileSettingsStore(InMemoryFileStore())),
|
|
),
|
|
):
|
|
client = TestClient(app)
|
|
yield client
|
|
|
|
|
|
def test_get_agent_settings_schema_includes_critic_verification_fields(test_client):
|
|
response = test_client.get('/api/v1/settings/agent-schema')
|
|
|
|
assert response.status_code == 200
|
|
schema = response.json()
|
|
section_keys = [s['key'] for s in schema['sections']]
|
|
assert 'verification' in section_keys
|
|
section = next(s for s in schema['sections'] if s['key'] == 'verification')
|
|
field_keys = [f['key'] for f in section['fields']]
|
|
assert 'verification.critic_enabled' in field_keys
|
|
assert 'confirmation_mode' not in field_keys
|
|
assert 'security_analyzer' not in field_keys
|
|
|
|
|
|
def test_get_conversation_settings_schema_endpoint(test_client):
|
|
response = test_client.get('/api/v1/settings/conversation-schema')
|
|
|
|
assert response.status_code == 200
|
|
schema = response.json()
|
|
assert schema['model_name'] == 'ConversationSettings'
|
|
section_keys = [s['key'] for s in schema['sections']]
|
|
assert section_keys == ['general', 'verification']
|
|
verification_section = next(
|
|
s for s in schema['sections'] if s['key'] == 'verification'
|
|
)
|
|
field_keys = [f['key'] for f in verification_section['fields']]
|
|
assert 'confirmation_mode' in field_keys
|
|
assert 'security_analyzer' in field_keys
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_settings_api_endpoints(test_client):
|
|
"""Test that the settings API endpoints work with the new auth system."""
|
|
settings = Settings(
|
|
language='en',
|
|
remote_runtime_resource_factor=2,
|
|
agent_settings=AgentSettings(
|
|
agent='test-agent',
|
|
llm=LLM(
|
|
model='test-model',
|
|
api_key=SecretStr('test-key'),
|
|
base_url='https://test.com',
|
|
timeout=123,
|
|
litellm_extra_body={'metadata': {'tier': 'pro'}},
|
|
),
|
|
verification=VerificationSettings(
|
|
critic_enabled=True,
|
|
critic_mode='all_actions',
|
|
enable_iterative_refinement=True,
|
|
critic_threshold=0.7,
|
|
max_refinement_iterations=4,
|
|
),
|
|
),
|
|
conversation_settings=ConversationSettings(
|
|
max_iterations=100,
|
|
confirmation_mode=True,
|
|
security_analyzer='llm',
|
|
),
|
|
)
|
|
|
|
# Make the POST request to store settings (V1 endpoint)
|
|
response = test_client.post('/api/v1/settings', json=_dump_update(settings))
|
|
|
|
# We're not checking the exact response, just that it doesn't error
|
|
assert response.status_code == 200
|
|
|
|
# Test the GET settings endpoint (V1 endpoint)
|
|
response = test_client.get('/api/v1/settings')
|
|
assert response.status_code == 200
|
|
response_data = response.json()
|
|
assert 'agent_settings_schema' not in response_data
|
|
vals = response_data['agent_settings']
|
|
assert vals['llm']['model'] == 'test-model'
|
|
assert vals['llm']['timeout'] == 123
|
|
assert vals['llm']['litellm_extra_body'] == {'metadata': {'tier': 'pro'}}
|
|
assert vals['verification']['critic_enabled'] is True
|
|
assert vals['verification']['critic_mode'] == 'all_actions'
|
|
assert vals['verification']['enable_iterative_refinement'] is True
|
|
assert vals['verification']['critic_threshold'] == 0.7
|
|
assert vals['verification']['max_refinement_iterations'] == 4
|
|
cs = response_data['conversation_settings']
|
|
assert cs['confirmation_mode'] is True
|
|
assert cs['security_analyzer'] == 'llm'
|
|
assert cs['max_iterations'] == 100
|
|
# V1 API sets api_key to None for security and uses llm_api_key_set flag instead
|
|
assert vals['llm']['api_key'] is None
|
|
assert response_data['llm_api_key_set'] is True
|
|
|
|
# Test updating with partial settings — legacy flat fields should preserve existing
|
|
partial_settings = {
|
|
'language': 'fr',
|
|
'llm_model': None,
|
|
'llm_api_key': None,
|
|
}
|
|
|
|
response = test_client.post('/api/v1/settings', json=partial_settings)
|
|
assert response.status_code == 200
|
|
|
|
response = test_client.get('/api/v1/settings')
|
|
assert response.status_code == 200
|
|
assert response.json()['agent_settings']['llm']['timeout'] == 123
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_store_settings_rejects_legacy_nested_payload_keys(test_client):
|
|
response = test_client.post(
|
|
'/api/v1/settings',
|
|
json={
|
|
'agent_settings': {'llm': {'model': 'legacy-model'}},
|
|
'conversation_settings': {'max_iterations': 5},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
assert response.json() == {
|
|
'error': 'Use *_diff nested settings payloads instead of legacy keys',
|
|
'keys': ['agent_settings', 'conversation_settings'],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_saving_settings_with_frozen_secrets_store(test_client):
|
|
"""Regression: POSTing settings must not fail with `secrets_store`.
|
|
|
|
See https://github.com/OpenHands/OpenHands/issues/13306.
|
|
"""
|
|
payload = _dump_update(
|
|
Settings(
|
|
language='en',
|
|
agent_settings=AgentSettings(llm=LLM(model='gpt-4')),
|
|
)
|
|
)
|
|
# Inject an extra key the API should ignore gracefully
|
|
payload['secrets_store'] = {'provider_tokens': {}}
|
|
response = test_client.post('/api/v1/settings', json=payload)
|
|
assert response.status_code == 200
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_api_key_explicit_clear(test_client):
|
|
"""Explicit empty search_api_key payloads should clear the stored secret."""
|
|
response = test_client.post(
|
|
'/api/v1/settings',
|
|
json=_dump_update(
|
|
Settings(
|
|
search_api_key='initial-secret-key',
|
|
agent_settings=AgentSettings(llm=LLM(model='gpt-4')),
|
|
)
|
|
),
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = test_client.get('/api/v1/settings')
|
|
assert response.status_code == 200
|
|
assert response.json()['search_api_key_set'] is True
|
|
|
|
response = test_client.post(
|
|
'/api/v1/settings',
|
|
json=_dump_update(
|
|
Settings(
|
|
search_api_key='',
|
|
agent_settings=AgentSettings(llm=LLM(model='claude-3-opus')),
|
|
)
|
|
),
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = test_client.get('/api/v1/settings')
|
|
assert response.status_code == 200
|
|
assert response.json()['search_api_key_set'] is False
|
|
assert response.json()['agent_settings']['llm']['model'] == 'claude-3-opus'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_disabled_skills_persistence(test_client):
|
|
"""Test that disabled_skills can be saved and retrieved via the settings API."""
|
|
response = test_client.post(
|
|
'/api/v1/settings',
|
|
json=_dump_update(
|
|
Settings(
|
|
disabled_skills=['skill_a', 'skill_b'],
|
|
agent_settings=AgentSettings(llm=LLM(model='test-model')),
|
|
)
|
|
),
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = test_client.get('/api/v1/settings')
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data['disabled_skills'] == ['skill_a', 'skill_b']
|
|
|
|
response = test_client.post(
|
|
'/api/v1/settings',
|
|
json=_dump(Settings(disabled_skills=['skill_c'])),
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = test_client.get('/api/v1/settings')
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data['disabled_skills'] == ['skill_c']
|
|
|
|
response = test_client.post(
|
|
'/api/v1/settings',
|
|
json=_dump(Settings(disabled_skills=[])),
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
response = test_client.get('/api/v1/settings')
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert data['disabled_skills'] == []
|