diff --git a/AGENTS.md b/AGENTS.md index 811f3bdcf0..b4facd9d19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/enterprise/storage/saas_settings_store.py b/enterprise/storage/saas_settings_store.py index b2fbdac2bd..d6061d1130 100644 --- a/enterprise/storage/saas_settings_store.py +++ b/enterprise/storage/saas_settings_store.py @@ -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: diff --git a/enterprise/storage/settings_projection.py b/enterprise/storage/settings_projection.py new file mode 100644 index 0000000000..ca273b5876 --- /dev/null +++ b/enterprise/storage/settings_projection.py @@ -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, + ) diff --git a/enterprise/storage/user_store.py b/enterprise/storage/user_store.py index ad42449285..2bc304b4d1 100644 --- a/enterprise/storage/user_store.py +++ b/enterprise/storage/user_store.py @@ -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 diff --git a/enterprise/tests/unit/test_saas_settings_store.py b/enterprise/tests/unit/test_saas_settings_store.py index 64f5013d1b..6ef7befc6b 100644 --- a/enterprise/tests/unit/test_saas_settings_store.py +++ b/enterprise/tests/unit/test_saas_settings_store.py @@ -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, diff --git a/openhands/storage/data_models/settings_groups.py b/openhands/storage/data_models/settings_groups.py new file mode 100644 index 0000000000..feadf13f97 --- /dev/null +++ b/openhands/storage/data_models/settings_groups.py @@ -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) diff --git a/tests/unit/storage/data_models/test_settings_groups.py b/tests/unit/storage/data_models/test_settings_groups.py new file mode 100644 index 0000000000..c6b93d03fa --- /dev/null +++ b/tests/unit/storage/data_models/test_settings_groups.py @@ -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'