Refactor V1 settings resolution through payloads

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
enyst
2026-03-19 16:29:13 +00:00
parent 120fd7516a
commit 5787f4179b
7 changed files with 451 additions and 124 deletions

View File

@@ -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`)
- Each mutation triggers immediate API call with query invalidation for UI updates
- Example: MCP settings, API Keys & Secrets tabs
- Benefits: Simpler UX, no risk of losing changes, consistent with modern web app patterns
**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 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
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:

View File

@@ -19,12 +19,13 @@ from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_ali
from storage.org import Org
from storage.org_member import OrgMember
from storage.org_store import OrgStore
from storage.settings_projection import build_resolved_settings
from storage.user import User
from storage.user_settings import UserSettings
from storage.user_store import UserStore
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.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}'
)
return None
kwargs = {
**{
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
return build_resolved_settings(user=user, org=org, org_member=org_member)
async def store(self, item: Settings):
async with a_session_maker() as session:

View 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,
)

View File

@@ -25,6 +25,7 @@ from storage.encrypt_utils import (
from storage.org import Org
from storage.org_member import OrgMember
from storage.role_store import RoleStore
from storage.settings_projection import build_user_settings
from storage.user import User
from storage.user_settings import UserSettings
from utils.identity import resolve_display_name
@@ -935,90 +936,13 @@ class UserStore:
def _create_user_settings_from_entities(
user_id: str, org_member: OrgMember, user: User, org: Org
) -> 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
a corresponding "default_" field in Org, use the Org value.
Also pulls relevant fields from User.
Args:
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,
return build_user_settings(
user_id=user_id,
user=user,
org=org,
org_member=org_member,
)
@staticmethod

View File

@@ -5,8 +5,7 @@ import pytest
from pydantic import SecretStr
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings as DataSettings
from openhands.storage.data_models.settings import Settings, SandboxGroupingStrategy
# Mock the database module before importing
with patch('storage.database.a_session_maker'):
@@ -26,6 +25,78 @@ def mock_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
def settings_store(async_session_maker, 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."""
store = SaasSettingsStore('test-user-id-123', mock_config)
existing_key = 'sk-existing-key'
item = DataSettings(
item = Settings(
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."""
store = SaasSettingsStore('test-user-id-123', mock_config)
new_key = 'sk-new-key'
item = DataSettings(
item = Settings(
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)
new_settings = DataSettings(
new_settings = Settings(
llm_model='new-shared-model/gpt-4',
llm_base_url='http://new-shared-url.com',
max_iterations=100,
@@ -417,7 +488,7 @@ async def test_store_updates_org_default_llm_settings(
store = SaasSettingsStore(admin_user_id, mock_config)
new_settings = DataSettings(
new_settings = Settings(
llm_model='anthropic/claude-sonnet-4',
llm_base_url='https://api.anthropic.com/v1',
max_iterations=75,

View 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)

View 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'