mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-03-22 05:37:20 +08:00
Refactor V1 settings resolution through payloads
Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
@@ -301,6 +301,7 @@ There are two main patterns for saving settings in the OpenHands frontend:
|
|||||||
- Uses dedicated mutation hooks for each operation (e.g., `use-add-mcp-server.ts`, `use-delete-mcp-server.ts`)
|
- Uses dedicated mutation hooks for each operation (e.g., `use-add-mcp-server.ts`, `use-delete-mcp-server.ts`)
|
||||||
- Each mutation triggers immediate API call with query invalidation for UI updates
|
- Each mutation triggers immediate API call with query invalidation for UI updates
|
||||||
- Example: MCP settings, API Keys & Secrets tabs
|
- Example: MCP settings, API Keys & Secrets tabs
|
||||||
|
|
||||||
- Benefits: Simpler UX, no risk of losing changes, consistent with modern web app patterns
|
- Benefits: Simpler UX, no risk of losing changes, consistent with modern web app patterns
|
||||||
|
|
||||||
**Pattern 2: Form-based Settings (Manual Save)**
|
**Pattern 2: Form-based Settings (Manual Save)**
|
||||||
@@ -317,6 +318,12 @@ There are two main patterns for saving settings in the OpenHands frontend:
|
|||||||
- Use Pattern 1 (Immediate Save) for entity management where each item is independent
|
- Use Pattern 1 (Immediate Save) for entity management where each item is independent
|
||||||
- Use Pattern 2 (Manual Save) for configuration forms where settings are interdependent or need validation
|
- Use Pattern 2 (Manual Save) for configuration forms where settings are interdependent or need validation
|
||||||
|
|
||||||
|
## Settings Separation Groundwork
|
||||||
|
- Shared, scope-free settings payloads now live in `openhands/storage/data_models/settings_groups.py`.
|
||||||
|
- `LLMProfile` is intentionally decoupled from `user_id` / `org_id` / `scope` and includes adapters to and from the SDK `openhands.sdk.llm.LLM` type.
|
||||||
|
- Enterprise currently projects `User`, `Org`, and `OrgMember` ORM entities into these payloads via `enterprise/storage/settings_projection.py`, then flattens them back into the app-server `Settings` model for the V1 execution path.
|
||||||
|
|
||||||
|
|
||||||
### Adding New LLM Models
|
### Adding New LLM Models
|
||||||
|
|
||||||
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:
|
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:
|
||||||
|
|||||||
@@ -19,12 +19,13 @@ from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_ali
|
|||||||
from storage.org import Org
|
from storage.org import Org
|
||||||
from storage.org_member import OrgMember
|
from storage.org_member import OrgMember
|
||||||
from storage.org_store import OrgStore
|
from storage.org_store import OrgStore
|
||||||
|
from storage.settings_projection import build_resolved_settings
|
||||||
from storage.user import User
|
from storage.user import User
|
||||||
from storage.user_settings import UserSettings
|
from storage.user_settings import UserSettings
|
||||||
from storage.user_store import UserStore
|
from storage.user_store import UserStore
|
||||||
|
|
||||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||||
from openhands.server.settings import Settings
|
from openhands.storage.data_models.settings import Settings
|
||||||
from openhands.storage.settings.settings_store import SettingsStore
|
from openhands.storage.settings.settings_store import SettingsStore
|
||||||
from openhands.utils.llm import is_openhands_model
|
from openhands.utils.llm import is_openhands_model
|
||||||
|
|
||||||
@@ -89,40 +90,7 @@ class SaasSettingsStore(SettingsStore):
|
|||||||
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
|
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
kwargs = {
|
return build_resolved_settings(user=user, org=org, org_member=org_member)
|
||||||
**{
|
|
||||||
normalized: getattr(org, c.name)
|
|
||||||
for c in Org.__table__.columns
|
|
||||||
if (
|
|
||||||
normalized := c.name.removeprefix('_default_')
|
|
||||||
.removeprefix('default_')
|
|
||||||
.lstrip('_')
|
|
||||||
)
|
|
||||||
in Settings.model_fields
|
|
||||||
},
|
|
||||||
**{
|
|
||||||
normalized: getattr(user, c.name)
|
|
||||||
for c in User.__table__.columns
|
|
||||||
if (normalized := c.name.lstrip('_')) in Settings.model_fields
|
|
||||||
},
|
|
||||||
}
|
|
||||||
kwargs['llm_api_key'] = org_member.llm_api_key
|
|
||||||
if org_member.max_iterations:
|
|
||||||
kwargs['max_iterations'] = org_member.max_iterations
|
|
||||||
if org_member.llm_model:
|
|
||||||
kwargs['llm_model'] = org_member.llm_model
|
|
||||||
if org_member.llm_api_key_for_byor:
|
|
||||||
kwargs['llm_api_key_for_byor'] = org_member.llm_api_key_for_byor
|
|
||||||
if org_member.llm_base_url:
|
|
||||||
kwargs['llm_base_url'] = org_member.llm_base_url
|
|
||||||
if org.v1_enabled is None:
|
|
||||||
kwargs['v1_enabled'] = True
|
|
||||||
# Apply default if sandbox_grouping_strategy is None in the database
|
|
||||||
if kwargs.get('sandbox_grouping_strategy') is None:
|
|
||||||
kwargs.pop('sandbox_grouping_strategy', None)
|
|
||||||
|
|
||||||
settings = Settings(**kwargs)
|
|
||||||
return settings
|
|
||||||
|
|
||||||
async def store(self, item: Settings):
|
async def store(self, item: Settings):
|
||||||
async with a_session_maker() as session:
|
async with a_session_maker() as session:
|
||||||
|
|||||||
157
enterprise/storage/settings_projection.py
Normal file
157
enterprise/storage/settings_projection.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from openhands.storage.data_models.settings import Settings, SandboxGroupingStrategy
|
||||||
|
from openhands.storage.data_models.settings_groups import (
|
||||||
|
AgentSettings,
|
||||||
|
LLMProfile,
|
||||||
|
ResourceSettings,
|
||||||
|
SettingsGroups,
|
||||||
|
UserSettingsPayload,
|
||||||
|
)
|
||||||
|
from storage.org import Org
|
||||||
|
from storage.org_member import OrgMember
|
||||||
|
from storage.user import User
|
||||||
|
from storage.user_settings import UserSettings
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sandbox_grouping_strategy(
|
||||||
|
value: object,
|
||||||
|
) -> SandboxGroupingStrategy | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, SandboxGroupingStrategy):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return SandboxGroupingStrategy(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def build_settings_groups(
|
||||||
|
user: User,
|
||||||
|
org: Org,
|
||||||
|
org_member: OrgMember,
|
||||||
|
) -> SettingsGroups:
|
||||||
|
"""Build scope-free settings payloads from enterprise ORM entities."""
|
||||||
|
|
||||||
|
return SettingsGroups(
|
||||||
|
llm=LLMProfile(
|
||||||
|
model=(
|
||||||
|
org_member.llm_model
|
||||||
|
if org_member.llm_model is not None
|
||||||
|
else org.default_llm_model
|
||||||
|
),
|
||||||
|
base_url=(
|
||||||
|
org_member.llm_base_url
|
||||||
|
if org_member.llm_base_url is not None
|
||||||
|
else org.default_llm_base_url
|
||||||
|
),
|
||||||
|
api_key=org_member.llm_api_key,
|
||||||
|
api_key_for_byor=org_member.llm_api_key_for_byor,
|
||||||
|
),
|
||||||
|
agent=AgentSettings(
|
||||||
|
agent=org.agent,
|
||||||
|
max_iterations=(
|
||||||
|
org_member.max_iterations
|
||||||
|
if org_member.max_iterations is not None
|
||||||
|
else org.default_max_iterations
|
||||||
|
),
|
||||||
|
security_analyzer=org.security_analyzer,
|
||||||
|
confirmation_mode=org.confirmation_mode,
|
||||||
|
enable_default_condenser=org.enable_default_condenser,
|
||||||
|
condenser_max_size=org.condenser_max_size,
|
||||||
|
),
|
||||||
|
resource=ResourceSettings(
|
||||||
|
mcp_config=org.mcp_config,
|
||||||
|
search_api_key=org.search_api_key,
|
||||||
|
sandbox_api_key=org.sandbox_api_key,
|
||||||
|
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
|
||||||
|
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters,
|
||||||
|
sandbox_base_container_image=org.sandbox_base_container_image,
|
||||||
|
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
|
||||||
|
max_budget_per_task=org.max_budget_per_task,
|
||||||
|
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||||
|
v1_enabled=org.v1_enabled,
|
||||||
|
sandbox_grouping_strategy=_normalize_sandbox_grouping_strategy(
|
||||||
|
getattr(user, 'sandbox_grouping_strategy', None)
|
||||||
|
if getattr(user, 'sandbox_grouping_strategy', None) is not None
|
||||||
|
else getattr(org, 'sandbox_grouping_strategy', None)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
user=UserSettingsPayload(
|
||||||
|
language=user.language,
|
||||||
|
enable_sound_notifications=user.enable_sound_notifications,
|
||||||
|
user_consents_to_analytics=user.user_consents_to_analytics,
|
||||||
|
accepted_tos=user.accepted_tos,
|
||||||
|
email=user.email,
|
||||||
|
email_verified=user.email_verified,
|
||||||
|
git_user_name=user.git_user_name,
|
||||||
|
git_user_email=user.git_user_email,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_resolved_settings(user: User, org: Org, org_member: OrgMember) -> Settings:
|
||||||
|
"""Build the shared app-server ``Settings`` model from enterprise entities."""
|
||||||
|
|
||||||
|
return build_settings_groups(user=user, org=org, org_member=org_member).to_settings()
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_settings(
|
||||||
|
user_id: str,
|
||||||
|
user: User,
|
||||||
|
org: Org,
|
||||||
|
org_member: OrgMember,
|
||||||
|
) -> UserSettings:
|
||||||
|
"""Build a legacy ``UserSettings`` row from normalized enterprise entities."""
|
||||||
|
|
||||||
|
groups = build_settings_groups(user=user, org=org, org_member=org_member)
|
||||||
|
|
||||||
|
return UserSettings(
|
||||||
|
keycloak_user_id=user_id,
|
||||||
|
language=groups.user.language,
|
||||||
|
agent=groups.agent.agent,
|
||||||
|
max_iterations=groups.agent.max_iterations,
|
||||||
|
security_analyzer=groups.agent.security_analyzer,
|
||||||
|
confirmation_mode=groups.agent.confirmation_mode,
|
||||||
|
llm_model=groups.llm.model,
|
||||||
|
llm_api_key=groups.llm.api_key.get_secret_value()
|
||||||
|
if groups.llm.api_key
|
||||||
|
else None,
|
||||||
|
llm_api_key_for_byor=groups.llm.api_key_for_byor.get_secret_value()
|
||||||
|
if groups.llm.api_key_for_byor
|
||||||
|
else None,
|
||||||
|
llm_base_url=groups.llm.base_url,
|
||||||
|
remote_runtime_resource_factor=groups.resource.remote_runtime_resource_factor,
|
||||||
|
enable_default_condenser=groups.agent.enable_default_condenser
|
||||||
|
if groups.agent.enable_default_condenser is not None
|
||||||
|
else True,
|
||||||
|
condenser_max_size=groups.agent.condenser_max_size,
|
||||||
|
user_consents_to_analytics=groups.user.user_consents_to_analytics,
|
||||||
|
accepted_tos=groups.user.accepted_tos,
|
||||||
|
billing_margin=org.billing_margin,
|
||||||
|
enable_sound_notifications=groups.user.enable_sound_notifications,
|
||||||
|
enable_proactive_conversation_starters=groups.resource.enable_proactive_conversation_starters,
|
||||||
|
sandbox_base_container_image=groups.resource.sandbox_base_container_image,
|
||||||
|
sandbox_runtime_container_image=groups.resource.sandbox_runtime_container_image,
|
||||||
|
user_version=org.org_version,
|
||||||
|
mcp_config=groups.resource.mcp_config,
|
||||||
|
search_api_key=groups.resource.search_api_key.get_secret_value()
|
||||||
|
if groups.resource.search_api_key
|
||||||
|
else None,
|
||||||
|
sandbox_api_key=groups.resource.sandbox_api_key.get_secret_value()
|
||||||
|
if groups.resource.sandbox_api_key
|
||||||
|
else None,
|
||||||
|
max_budget_per_task=groups.resource.max_budget_per_task,
|
||||||
|
enable_solvability_analysis=groups.resource.enable_solvability_analysis,
|
||||||
|
email=groups.user.email,
|
||||||
|
email_verified=groups.user.email_verified,
|
||||||
|
git_user_name=groups.user.git_user_name,
|
||||||
|
git_user_email=groups.user.git_user_email,
|
||||||
|
v1_enabled=groups.resource.v1_enabled,
|
||||||
|
sandbox_grouping_strategy=groups.resource.sandbox_grouping_strategy,
|
||||||
|
already_migrated=False,
|
||||||
|
)
|
||||||
@@ -25,6 +25,7 @@ from storage.encrypt_utils import (
|
|||||||
from storage.org import Org
|
from storage.org import Org
|
||||||
from storage.org_member import OrgMember
|
from storage.org_member import OrgMember
|
||||||
from storage.role_store import RoleStore
|
from storage.role_store import RoleStore
|
||||||
|
from storage.settings_projection import build_user_settings
|
||||||
from storage.user import User
|
from storage.user import User
|
||||||
from storage.user_settings import UserSettings
|
from storage.user_settings import UserSettings
|
||||||
from utils.identity import resolve_display_name
|
from utils.identity import resolve_display_name
|
||||||
@@ -935,90 +936,13 @@ class UserStore:
|
|||||||
def _create_user_settings_from_entities(
|
def _create_user_settings_from_entities(
|
||||||
user_id: str, org_member: OrgMember, user: User, org: Org
|
user_id: str, org_member: OrgMember, user: User, org: Org
|
||||||
) -> UserSettings:
|
) -> UserSettings:
|
||||||
"""Create UserSettings from OrgMember, User, and Org data.
|
"""Create UserSettings from enterprise entities via scope-free payloads."""
|
||||||
|
|
||||||
Uses OrgMember values first. If an OrgMember field is None and there's
|
return build_user_settings(
|
||||||
a corresponding "default_" field in Org, use the Org value.
|
user_id=user_id,
|
||||||
Also pulls relevant fields from User.
|
user=user,
|
||||||
|
org=org,
|
||||||
Args:
|
org_member=org_member,
|
||||||
user_id: The Keycloak user ID
|
|
||||||
org_member: The OrgMember entity
|
|
||||||
user: The User entity
|
|
||||||
org: The Org entity
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A new UserSettings object populated from the entities
|
|
||||||
"""
|
|
||||||
# Mapping from OrgMember fields to corresponding Org "default_" fields
|
|
||||||
org_member_to_org_default = {
|
|
||||||
'llm_model': 'default_llm_model',
|
|
||||||
'llm_base_url': 'default_llm_base_url',
|
|
||||||
'max_iterations': 'default_max_iterations',
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_value_with_org_fallback(field_name: str, org_member_value):
|
|
||||||
"""Get value from OrgMember, falling back to Org default if None."""
|
|
||||||
if org_member_value is not None:
|
|
||||||
return org_member_value
|
|
||||||
org_default_field = org_member_to_org_default.get(field_name)
|
|
||||||
if org_default_field and hasattr(org, org_default_field):
|
|
||||||
return getattr(org, org_default_field)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Get values from OrgMember with Org fallback for fields with default_ prefix
|
|
||||||
llm_model = get_value_with_org_fallback('llm_model', org_member.llm_model)
|
|
||||||
llm_base_url = get_value_with_org_fallback(
|
|
||||||
'llm_base_url', org_member.llm_base_url
|
|
||||||
)
|
|
||||||
max_iterations = get_value_with_org_fallback(
|
|
||||||
'max_iterations', org_member.max_iterations
|
|
||||||
)
|
|
||||||
|
|
||||||
return UserSettings(
|
|
||||||
keycloak_user_id=user_id,
|
|
||||||
# OrgMember fields
|
|
||||||
llm_api_key=org_member.llm_api_key.get_secret_value()
|
|
||||||
if org_member.llm_api_key
|
|
||||||
else None,
|
|
||||||
llm_api_key_for_byor=org_member.llm_api_key_for_byor.get_secret_value()
|
|
||||||
if org_member.llm_api_key_for_byor
|
|
||||||
else None,
|
|
||||||
llm_model=llm_model,
|
|
||||||
llm_base_url=llm_base_url,
|
|
||||||
max_iterations=max_iterations,
|
|
||||||
# User fields
|
|
||||||
accepted_tos=user.accepted_tos,
|
|
||||||
enable_sound_notifications=user.enable_sound_notifications,
|
|
||||||
language=user.language,
|
|
||||||
user_consents_to_analytics=user.user_consents_to_analytics,
|
|
||||||
email=user.email,
|
|
||||||
email_verified=user.email_verified,
|
|
||||||
git_user_name=user.git_user_name,
|
|
||||||
git_user_email=user.git_user_email,
|
|
||||||
# Org fields
|
|
||||||
agent=org.agent,
|
|
||||||
security_analyzer=org.security_analyzer,
|
|
||||||
confirmation_mode=org.confirmation_mode,
|
|
||||||
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
|
|
||||||
enable_default_condenser=org.enable_default_condenser,
|
|
||||||
billing_margin=org.billing_margin,
|
|
||||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters,
|
|
||||||
sandbox_base_container_image=org.sandbox_base_container_image,
|
|
||||||
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
|
|
||||||
user_version=org.org_version,
|
|
||||||
mcp_config=org.mcp_config,
|
|
||||||
search_api_key=org.search_api_key.get_secret_value()
|
|
||||||
if org.search_api_key
|
|
||||||
else None,
|
|
||||||
sandbox_api_key=org.sandbox_api_key.get_secret_value()
|
|
||||||
if org.sandbox_api_key
|
|
||||||
else None,
|
|
||||||
max_budget_per_task=org.max_budget_per_task,
|
|
||||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
|
||||||
v1_enabled=org.v1_enabled,
|
|
||||||
condenser_max_size=org.condenser_max_size,
|
|
||||||
already_migrated=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import pytest
|
|||||||
from pydantic import SecretStr
|
from pydantic import SecretStr
|
||||||
|
|
||||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||||
from openhands.server.settings import Settings
|
from openhands.storage.data_models.settings import Settings, SandboxGroupingStrategy
|
||||||
from openhands.storage.data_models.settings import Settings as DataSettings
|
|
||||||
|
|
||||||
# Mock the database module before importing
|
# Mock the database module before importing
|
||||||
with patch('storage.database.a_session_maker'):
|
with patch('storage.database.a_session_maker'):
|
||||||
@@ -26,6 +25,78 @@ def mock_config():
|
|||||||
return config
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_resolves_v1_settings_from_scope_free_groups(mock_config):
|
||||||
|
store = SaasSettingsStore('5594c7b6-f959-4b81-92e9-b09c206f5081', mock_config)
|
||||||
|
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5082')
|
||||||
|
|
||||||
|
org_member = MagicMock()
|
||||||
|
org_member.org_id = org_id
|
||||||
|
org_member.llm_api_key = SecretStr('member-api-key')
|
||||||
|
org_member.llm_api_key_for_byor = None
|
||||||
|
org_member.llm_model = None
|
||||||
|
org_member.llm_base_url = 'https://member.example.com'
|
||||||
|
org_member.max_iterations = 12
|
||||||
|
|
||||||
|
user = MagicMock()
|
||||||
|
user.current_org_id = org_id
|
||||||
|
user.org_members = [org_member]
|
||||||
|
user.language = 'fr'
|
||||||
|
user.enable_sound_notifications = False
|
||||||
|
user.user_consents_to_analytics = True
|
||||||
|
user.accepted_tos = None
|
||||||
|
user.email = 'user@example.com'
|
||||||
|
user.email_verified = True
|
||||||
|
user.git_user_name = 'openhands'
|
||||||
|
user.git_user_email = 'openhands@example.com'
|
||||||
|
user.sandbox_grouping_strategy = None
|
||||||
|
|
||||||
|
org = MagicMock()
|
||||||
|
org.default_llm_model = 'anthropic/claude-sonnet-4-5-20250929'
|
||||||
|
org.default_llm_base_url = 'https://org.example.com'
|
||||||
|
org.default_max_iterations = 50
|
||||||
|
org.agent = 'CodeActAgent'
|
||||||
|
org.security_analyzer = 'static'
|
||||||
|
org.confirmation_mode = False
|
||||||
|
org.enable_default_condenser = True
|
||||||
|
org.condenser_max_size = 256
|
||||||
|
org.mcp_config = None
|
||||||
|
org.search_api_key = SecretStr('search-key')
|
||||||
|
org.sandbox_api_key = None
|
||||||
|
org.remote_runtime_resource_factor = 2
|
||||||
|
org.enable_proactive_conversation_starters = False
|
||||||
|
org.sandbox_base_container_image = 'base:latest'
|
||||||
|
org.sandbox_runtime_container_image = 'runtime:latest'
|
||||||
|
org.max_budget_per_task = 5.0
|
||||||
|
org.enable_solvability_analysis = True
|
||||||
|
org.v1_enabled = None
|
||||||
|
org.sandbox_grouping_strategy = None
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
'storage.saas_settings_store.UserStore.get_user_by_id',
|
||||||
|
new=AsyncMock(return_value=user),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
'storage.saas_settings_store.OrgStore.get_org_by_id_async',
|
||||||
|
new=AsyncMock(return_value=org),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
settings = await store.load()
|
||||||
|
|
||||||
|
assert settings is not None
|
||||||
|
assert settings.llm_model == 'anthropic/claude-sonnet-4-5-20250929'
|
||||||
|
assert settings.llm_base_url == 'https://member.example.com'
|
||||||
|
assert settings.max_iterations == 12
|
||||||
|
assert settings.llm_api_key is not None
|
||||||
|
assert settings.llm_api_key.get_secret_value() == 'member-api-key'
|
||||||
|
assert settings.language == 'fr'
|
||||||
|
assert settings.email == 'user@example.com'
|
||||||
|
assert settings.v1_enabled is True
|
||||||
|
assert settings.sandbox_grouping_strategy == SandboxGroupingStrategy.NO_GROUPING
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def settings_store(async_session_maker, mock_config):
|
def settings_store(async_session_maker, mock_config):
|
||||||
store = SaasSettingsStore('5594c7b6-f959-4b81-92e9-b09c206f5081', mock_config)
|
store = SaasSettingsStore('5594c7b6-f959-4b81-92e9-b09c206f5081', mock_config)
|
||||||
@@ -191,7 +262,7 @@ async def test_ensure_api_key_keeps_valid_key(mock_config):
|
|||||||
"""When the existing key is valid, it should be kept unchanged."""
|
"""When the existing key is valid, it should be kept unchanged."""
|
||||||
store = SaasSettingsStore('test-user-id-123', mock_config)
|
store = SaasSettingsStore('test-user-id-123', mock_config)
|
||||||
existing_key = 'sk-existing-key'
|
existing_key = 'sk-existing-key'
|
||||||
item = DataSettings(
|
item = Settings(
|
||||||
llm_model='openhands/gpt-4', llm_api_key=SecretStr(existing_key)
|
llm_model='openhands/gpt-4', llm_api_key=SecretStr(existing_key)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -214,7 +285,7 @@ async def test_ensure_api_key_generates_new_key_when_verification_fails(
|
|||||||
"""When verification fails, a new key should be generated."""
|
"""When verification fails, a new key should be generated."""
|
||||||
store = SaasSettingsStore('test-user-id-123', mock_config)
|
store = SaasSettingsStore('test-user-id-123', mock_config)
|
||||||
new_key = 'sk-new-key'
|
new_key = 'sk-new-key'
|
||||||
item = DataSettings(
|
item = Settings(
|
||||||
llm_model='openhands/gpt-4', llm_api_key=SecretStr('sk-invalid-key')
|
llm_model='openhands/gpt-4', llm_api_key=SecretStr('sk-invalid-key')
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -356,7 +427,7 @@ async def test_store_propagates_llm_settings_to_all_org_members(
|
|||||||
|
|
||||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||||
|
|
||||||
new_settings = DataSettings(
|
new_settings = Settings(
|
||||||
llm_model='new-shared-model/gpt-4',
|
llm_model='new-shared-model/gpt-4',
|
||||||
llm_base_url='http://new-shared-url.com',
|
llm_base_url='http://new-shared-url.com',
|
||||||
max_iterations=100,
|
max_iterations=100,
|
||||||
@@ -417,7 +488,7 @@ async def test_store_updates_org_default_llm_settings(
|
|||||||
|
|
||||||
store = SaasSettingsStore(admin_user_id, mock_config)
|
store = SaasSettingsStore(admin_user_id, mock_config)
|
||||||
|
|
||||||
new_settings = DataSettings(
|
new_settings = Settings(
|
||||||
llm_model='anthropic/claude-sonnet-4',
|
llm_model='anthropic/claude-sonnet-4',
|
||||||
llm_base_url='https://api.anthropic.com/v1',
|
llm_base_url='https://api.anthropic.com/v1',
|
||||||
max_iterations=75,
|
max_iterations=75,
|
||||||
|
|||||||
136
openhands/storage/data_models/settings_groups.py
Normal file
136
openhands/storage/data_models/settings_groups.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, SecretStr
|
||||||
|
|
||||||
|
from openhands.core.config.mcp_config import MCPConfig
|
||||||
|
from openhands.storage.data_models.settings import SandboxGroupingStrategy, Settings
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from openhands.sdk.llm.llm import LLM as SDKLLM
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProfile(BaseModel):
|
||||||
|
"""Scope-free LLM settings payload.
|
||||||
|
|
||||||
|
The SDK currently persists full ``openhands.sdk.llm.LLM`` objects in its
|
||||||
|
profile store. This payload keeps just the profile fields currently present
|
||||||
|
on the app-server execution path, while still supporting conversion to and
|
||||||
|
from the SDK ``LLM`` object when needed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model: str | None = None
|
||||||
|
base_url: str | None = None
|
||||||
|
api_key: SecretStr | None = None
|
||||||
|
api_key_for_byor: SecretStr | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_sdk_llm(cls, llm: 'SDKLLM') -> 'LLMProfile':
|
||||||
|
"""Create a lightweight profile payload from an SDK ``LLM``."""
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
model=llm.model,
|
||||||
|
base_url=llm.base_url,
|
||||||
|
api_key=llm.api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_sdk_llm(self, **overrides: Any) -> 'SDKLLM':
|
||||||
|
"""Promote this profile payload to an SDK ``LLM`` instance."""
|
||||||
|
|
||||||
|
from openhands.sdk.llm.llm import LLM
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
'model': self.model,
|
||||||
|
'base_url': self.base_url,
|
||||||
|
'api_key': self.api_key,
|
||||||
|
**overrides,
|
||||||
|
}
|
||||||
|
return LLM(**{key: value for key, value in kwargs.items() if value is not None})
|
||||||
|
|
||||||
|
|
||||||
|
class AgentSettings(BaseModel):
|
||||||
|
"""Scope-free agent execution settings."""
|
||||||
|
|
||||||
|
agent: str | None = None
|
||||||
|
max_iterations: int | None = None
|
||||||
|
security_analyzer: str | None = None
|
||||||
|
confirmation_mode: bool | None = None
|
||||||
|
enable_default_condenser: bool | None = None
|
||||||
|
condenser_max_size: int | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceSettings(BaseModel):
|
||||||
|
"""Scope-free runtime and resource settings."""
|
||||||
|
|
||||||
|
mcp_config: MCPConfig | dict[str, Any] | None = None
|
||||||
|
search_api_key: SecretStr | None = None
|
||||||
|
sandbox_api_key: SecretStr | None = None
|
||||||
|
remote_runtime_resource_factor: int | None = None
|
||||||
|
enable_proactive_conversation_starters: bool | None = None
|
||||||
|
sandbox_base_container_image: str | None = None
|
||||||
|
sandbox_runtime_container_image: str | None = None
|
||||||
|
max_budget_per_task: float | None = None
|
||||||
|
enable_solvability_analysis: bool | None = None
|
||||||
|
v1_enabled: bool | None = None
|
||||||
|
sandbox_grouping_strategy: SandboxGroupingStrategy | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsPayload(BaseModel):
|
||||||
|
"""Scope-free user preferences and profile fields."""
|
||||||
|
|
||||||
|
language: str | None = None
|
||||||
|
enable_sound_notifications: bool | None = None
|
||||||
|
user_consents_to_analytics: bool | None = None
|
||||||
|
accepted_tos: datetime | None = None
|
||||||
|
email: str | None = None
|
||||||
|
email_verified: bool | None = None
|
||||||
|
git_user_name: str | None = None
|
||||||
|
git_user_email: str | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsGroups(BaseModel):
|
||||||
|
"""Grouped settings payloads decoupled from storage scope metadata."""
|
||||||
|
|
||||||
|
llm: LLMProfile = Field(default_factory=LLMProfile)
|
||||||
|
agent: AgentSettings = Field(default_factory=AgentSettings)
|
||||||
|
resource: ResourceSettings = Field(default_factory=ResourceSettings)
|
||||||
|
user: UserSettingsPayload = Field(default_factory=UserSettingsPayload)
|
||||||
|
|
||||||
|
model_config = ConfigDict(validate_assignment=True)
|
||||||
|
|
||||||
|
def to_settings(self) -> Settings:
|
||||||
|
"""Flatten grouped payloads into the shared app-server ``Settings`` model."""
|
||||||
|
|
||||||
|
settings_kwargs: dict[str, object] = {}
|
||||||
|
|
||||||
|
llm_mappings = {
|
||||||
|
'llm_model': self.llm.model,
|
||||||
|
'llm_base_url': self.llm.base_url,
|
||||||
|
'llm_api_key': self.llm.api_key,
|
||||||
|
}
|
||||||
|
for field_name, value in llm_mappings.items():
|
||||||
|
if value is not None:
|
||||||
|
settings_kwargs[field_name] = value
|
||||||
|
|
||||||
|
for field_name, value in self.agent.model_dump(exclude_none=True).items():
|
||||||
|
settings_kwargs[field_name] = value
|
||||||
|
|
||||||
|
for field_name, value in self.resource.model_dump(exclude_none=True).items():
|
||||||
|
settings_kwargs[field_name] = value
|
||||||
|
|
||||||
|
for field_name, value in self.user.model_dump(exclude_none=True).items():
|
||||||
|
if field_name == 'accepted_tos':
|
||||||
|
continue
|
||||||
|
settings_kwargs[field_name] = value
|
||||||
|
|
||||||
|
return Settings.model_validate(settings_kwargs)
|
||||||
64
tests/unit/storage/data_models/test_settings_groups.py
Normal file
64
tests/unit/storage/data_models/test_settings_groups.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from pydantic import SecretStr
|
||||||
|
|
||||||
|
from openhands.storage.data_models.settings import SandboxGroupingStrategy
|
||||||
|
from openhands.storage.data_models.settings_groups import (
|
||||||
|
AgentSettings,
|
||||||
|
LLMProfile,
|
||||||
|
ResourceSettings,
|
||||||
|
SettingsGroups,
|
||||||
|
UserSettingsPayload,
|
||||||
|
)
|
||||||
|
|
||||||
|
os.environ.setdefault('OPENHANDS_SUPPRESS_BANNER', '1')
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_groups_to_settings_keeps_scope_fields_out_of_payloads() -> None:
|
||||||
|
groups = SettingsGroups(
|
||||||
|
llm=LLMProfile(
|
||||||
|
model='anthropic/claude-sonnet-4-5-20250929',
|
||||||
|
base_url='https://llm.example.com',
|
||||||
|
api_key=SecretStr('secret-key'),
|
||||||
|
),
|
||||||
|
agent=AgentSettings(
|
||||||
|
agent='CodeActAgent',
|
||||||
|
max_iterations=42,
|
||||||
|
confirmation_mode=False,
|
||||||
|
),
|
||||||
|
resource=ResourceSettings(),
|
||||||
|
user=UserSettingsPayload(language='fr', enable_sound_notifications=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
dumped = json.dumps(groups.model_dump(mode='json'))
|
||||||
|
for forbidden_field in ('scope', 'org_id', 'user_id', 'keycloak_user_id'):
|
||||||
|
assert forbidden_field not in dumped
|
||||||
|
|
||||||
|
settings = groups.to_settings()
|
||||||
|
assert settings.llm_model == 'anthropic/claude-sonnet-4-5-20250929'
|
||||||
|
assert settings.llm_base_url == 'https://llm.example.com'
|
||||||
|
assert settings.llm_api_key is not None
|
||||||
|
assert settings.llm_api_key.get_secret_value() == 'secret-key'
|
||||||
|
assert settings.max_iterations == 42
|
||||||
|
assert settings.language == 'fr'
|
||||||
|
assert settings.v1_enabled is True
|
||||||
|
assert settings.sandbox_grouping_strategy == SandboxGroupingStrategy.NO_GROUPING
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_profile_round_trips_with_sdk_llm() -> None:
|
||||||
|
profile = LLMProfile(
|
||||||
|
model='openai/gpt-4o',
|
||||||
|
base_url='https://api.example.com',
|
||||||
|
api_key=SecretStr('sdk-secret'),
|
||||||
|
)
|
||||||
|
|
||||||
|
sdk_llm = profile.to_sdk_llm(usage_id='fast', temperature=0.3)
|
||||||
|
round_trip = LLMProfile.from_sdk_llm(sdk_llm)
|
||||||
|
|
||||||
|
assert sdk_llm.usage_id == 'fast'
|
||||||
|
assert sdk_llm.temperature == 0.3
|
||||||
|
assert round_trip.model == 'openai/gpt-4o'
|
||||||
|
assert round_trip.base_url == 'https://api.example.com'
|
||||||
|
assert round_trip.api_key is not None
|
||||||
|
assert round_trip.api_key.get_secret_value() == 'sdk-secret'
|
||||||
Reference in New Issue
Block a user