Compare commits

...

74 Commits

Author SHA1 Message Date
openhands
a52e5bdc26 Merge main into PR 13306
Resolve settings-related merge conflicts while preserving split condenser and verification pages.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-26 07:42:33 +00:00
openhands
e9b0f7b5c1 Align org LLM settings with main behavior
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-26 00:08:09 +00:00
openhands
36ff8bcb9c fix: restore CI-critical settings PR files
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 10:39:05 +00:00
openhands
8d9841e95a chore: trim unrelated settings PR changes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 08:24:21 +00:00
openhands
0ca9528afe Require newer Poetry for builds
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 07:54:21 +00:00
openhands
78f877aca5 Address settings review feedback
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 06:46:39 +00:00
openhands
9e6f5bae9c Remove legacy org settings columns
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 06:30:43 +00:00
openhands
b104c35075 Fix enterprise org settings test expectations
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 06:05:20 +00:00
openhands
c1958bef4d Use canonical agent settings for enterprise orgs
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 05:44:23 +00:00
openhands
80dc1b9a38 Merge remote-tracking branch 'origin/main' into pr-13306 2026-03-24 05:21:33 +00:00
openhands
3df73ea5eb Merge main into settings schema branch
Resolve saas_settings_store key-generation conflict while keeping canonical snapshot settings handling.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 04:22:43 +00:00
openhands
1f88750ade Simplify org member settings snapshots
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 02:03:30 +00:00
openhands
c3341e3a0e Centralize BYOR secret handling on UserSettings
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 01:14:31 +00:00
openhands
14a234cdbe Use settings snapshots as enterprise source of truth
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-24 00:49:52 +00:00
openhands
806da849c5 enterprise: persist full org member settings 2026-03-23 23:29:36 +00:00
openhands
5a47e52176 settings: canonicalize SDK agent settings
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 23:06:36 +00:00
openhands
cdf0ac8421 enterprise: combine agent_settings migrations
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 21:30:19 +00:00
openhands
1dd4d0fc9d Stabilize websocket sendMessage state reads
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:50:55 +00:00
openhands
c07a85aec0 Preserve hidden sentinel during settings serialization
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:32:33 +00:00
openhands
13f244e6e7 Merge origin/main into canonical settings migration branch
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:21:29 +00:00
openhands
5477028bc8 Fix canonical settings test coverage and secret masking
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 13:20:10 +00:00
openhands
e930a51f05 Use canonical agent_settings across OpenHands settings flows
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 12:40:14 +00:00
openhands
cf877b5628 Require newer Poetry in app image
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:31:26 +00:00
openhands
fb9958aff8 Normalize migration style
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:22:34 +00:00
openhands
c1f5861eaf Fix migration lint
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:16:37 +00:00
openhands
fa7f58b7c5 Consolidate enterprise user settings
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-23 01:08:45 +00:00
openhands
a691bec7fc fix(settings): keep persistence user-facing
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 23:08:16 +00:00
openhands
7eb77c131d Fix frozen settings normalization
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:54:38 +00:00
openhands
858870a095 Format enterprise settings changes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:42:57 +00:00
openhands
d65e5b5e46 Fix post-merge lint regressions
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:35:51 +00:00
openhands
2b0816f53a Merge main into settings schema PR
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 20:10:16 +00:00
openhands
ab9536dc6b Scope SaaS agent settings to member fields
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:37:24 +00:00
openhands
9f5888315a Harden SaaS settings schema migration
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:31:39 +00:00
openhands
fcefb872b6 Normalize canonical settings fields in frontend
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 19:13:31 +00:00
openhands
b91cd0570e Fix enterprise settings schema for preview auth
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-22 00:33:22 +00:00
openhands
e18775b391 fix: avoid websocket send fallback race in frontend tests
- send via the websocket hook's live ref-backed sender
- treat OPEN connection state as authoritative for socket delivery
- prevent flaky fallback to pending-message REST queue during sends

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 22:46:54 +00:00
openhands
495de35139 fix: restore enterprise settings compatibility for SDK schema PR
- add backward-compatible Settings setters and sdk_settings_values alias
- update SaaS settings store org default mapping for agent_settings
- refresh enterprise test helper for agent_settings-backed settings

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 22:14:11 +00:00
openhands
be29d89b3c Merge main and refresh settings schema PR for current CI
- merge latest main into the gui settings schema branch
- regenerate root and enterprise lockfiles after dependency changes
- fix stale llm-settings test to use sdk_settings_schema

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 21:59:17 +00:00
Graham Neubig
2890b8c6ff fix: default settings entry point to LLM page instead of Integrations
Home page 'Settings' buttons (ConnectToProviderMessage, task-suggestions)
linked to /settings/integrations. Change to /settings so users land on the
LLM settings page (first nav item in OSS mode).

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 05:19:22 +09:00
Graham Neubig
39a846ccc3 fix: persist LLM provider prefix and show API key set indicator
- llm-settings.tsx: construct full model name (provider/model) in
  CriticalFields onChange, matching the convention used everywhere else
- settings.py: redact set secrets to '<hidden>' instead of None so the
  frontend can distinguish 'set but redacted' from 'not set'
- settings.py: reject '<hidden>' sentinel in _apply_settings_payload to
  prevent accidental overwrite of real secrets
- Fix llm-settings test to use agent_settings/agent_settings_schema names

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 04:57:50 +09:00
openhands
cae7b6e72f chore: refresh SDK lockfile refs again
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:46:06 +00:00
openhands
7ca41486be chore: refresh SDK lockfile refs
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:39:41 +00:00
openhands
81c02623a1 merge: update settings schema branch with main and SDK
- merge latest main into the GUI settings schema branch
- keep schema-driven LLM settings page and tests after conflict resolution
- update lockfiles to SDK branch head c333aedd

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 02:35:30 +00:00
openhands
38dcf959bc fix: restore search_api_key check in hasAdvancedSettingsSet lost during merge
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:41:56 +00:00
openhands
ef3acf726c style: apply ruff-format fixes
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:35:25 +00:00
openhands
017d758a76 fix: skip frozen fields when applying settings payload
The secrets_store field on Settings is frozen, so setattr() raised a
validation error when the POST /api/settings payload included that key.

Filter out any frozen field before calling setattr in
_apply_settings_payload. Added a regression test.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:04:46 +00:00
openhands
3ed37e18ac Merge main into openhands/issue-2228-gui-settings-schema
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:00:39 +00:00
openhands
1322f944be refactor: settings are transparent — no backend auto-fill
critic_server_url and critic_model_name are now user-facing settings
exposed in the GUI. The backend no longer mutates them based on proxy
detection. _get_agent_settings is now a pure pass-through with model
override only.

SDK pin: b69f7cee

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 12:06:58 +00:00
openhands
5925483f6b chore: update SDK pin to 0030eed1 (mcp_config + schema versioning)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:58:15 +00:00
openhands
0144424c8e fix: remove dead security_analyzer override on Agent
security_analyzer lives in VerificationSettings, not on Agent.
The model_copy override was a no-op.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:54:20 +00:00
openhands
f07ce85b45 refactor: flow mcp_config through settings, not runtime override
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:51:01 +00:00
openhands
bc5a46dcee simplify: settings-transparent agent creation
- _get_agent_settings resolves ALL settings (model, critic endpoint)
- _create_agent just calls settings.create_agent() + runtime overrides
- Eliminated _get_default_critic, _apply_critic_proxy, _CRITIC_PROXY_PATTERN
- Removed legacy path (agent_settings is always present)
- Replaced mock-heavy tests with real-object assertions (-200 lines)

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:44:41 +00:00
openhands
9990870060 refactor: use AgentSettings.create_agent() for V1 agent creation
Replace manual Agent construction with settings.create_agent() when
SDK AgentSettings are available.  This makes settings the single
source of truth for LLM, tools, condenser, critic, and agent context.

Key changes:
- _get_default_critic() eliminated — replaced by _apply_critic_proxy()
  which sets critic_server_url/critic_model_name on the settings, then
  lets the SDK's build_critic() do the construction.
- _create_agent_with_context() settings path: populate tools +
  agent_context on settings, call create_agent(), apply runtime-only
  overrides (mcp_config, system_prompt) via model_copy.
- Legacy path (no agent_settings) kept for backward compat.
- Tests updated: agent_context now in Agent() constructor,
  mcp_config/system_prompt in model_copy.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 11:30:02 +00:00
openhands
bab9a45590 chore: regenerate lock files to pick up SDK VerificationSettings commit
Update poetry.lock, enterprise/poetry.lock, and uv.lock to resolve
to SDK commit bb601665 which includes the merged VerificationSettings.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 02:44:33 +00:00
openhands
b4107ff9dc settings: merge Critic+Security into Verification, remove OpenHandsAgentSettings
- SDK: combined CriticSettings + SecuritySettings into VerificationSettings
  with backward-compat property accessors and type aliases
- Removed OpenHandsAgentSettings subclass — use AgentSettings directly
- Nav order: LLM → Condenser → Verification (was separate Security + Critic)
- Single verification-settings route replaces critic-settings + security-settings
- Updated _SDK_TO_FLAT_SETTINGS keys to verification.* namespace
- All 119 backend tests pass, frontend builds, lint clean

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 02:31:05 +00:00
openhands
3e04713097 Merge remote-tracking branch 'origin/main' into openhands/issue-2228-gui-settings-schema 2026-03-17 02:28:36 +00:00
openhands
77f868081c feat: add Security settings section via OpenHandsAgentSettings
Create OpenHandsAgentSettings(AgentSettings) in the OpenHands codebase
that extends the SDK's AgentSettings with a 'security' section containing
confirmation_mode (critical) and security_analyzer (major). The SDK's
export_schema() picks these up automatically via its metadata conventions.

Backend:
- SecuritySettings pydantic model with SDK metadata annotations
- OpenHandsAgentSettings subclass used by _get_sdk_settings_schema()
- _SDK_TO_FLAT_SETTINGS bridges dotted SDK keys to flat Settings attrs
  so existing consumers (session init, security-analyzer setup) work
- _extract_sdk_settings_values seeds from flat fields for UI display

Frontend:
- /settings/security route renders the security schema section
- Nav: LLM -> Security -> Condenser -> Critic (both SAAS and OSS)
- Removed empty General page (no schema section exists for it yet)

Tests:
- New test_get_sdk_settings_schema_includes_security_section
- All 119 backend + 10 frontend tests pass

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 02:08:07 +00:00
openhands
3a12924bc8 refactor: add General/Security pages, remove SDK_LEGACY_FIELD_MAP, fix inferInitialView
- Add /settings/general and /settings/security sidebar pages rendering
  their respective SDK schema sections
- Reorder nav: General above LLM, Security below LLM (both SAAS + OSS)
- Remove SDK_LEGACY_FIELD_MAP and all legacy field bridging — the only
  canonical store for SDK settings is now sdk_settings_values
- Simplify to_agent_settings(), _extract_sdk_settings_values(), and
  _apply_settings_payload() to read/write sdk_settings_values only
- Fix inferInitialView to accept an optional schemaOverride so
  SdkSectionPage passes filteredSchema (prevents cross-section
  minor-value overrides from elevating the view tier on unrelated pages)
- Add SETTINGS$NAV_GENERAL and SETTINGS$NAV_SECURITY i18n keys with
  translations for all 14 languages
- Use lock.svg for Security icon and settings.svg for General icon

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 01:46:11 +00:00
openhands
cfa7def554 feat: split Condenser and Critic into separate sidebar settings pages
- Add /settings/condenser and /settings/critic routes alongside LLM
- Extract reusable SdkSectionPage component with render-prop header
- Extract SchemaField + FIELD_HELP_LINKS into shared sdk-settings module
- LLM page now only renders 'llm' section; condenser/critic each render
  their own section using the same generic SdkSectionPage
- Add nav items with MemoryIcon (Condenser) and LightbulbIcon (Critic)
  to both SAAS_NAV_ITEMS and OSS_NAV_ITEMS
- Add SETTINGS$NAV_CONDENSER and SETTINGS$NAV_CRITIC i18n keys with
  translations for all 14 supported languages
- Update tests to reflect LLM-only page scope

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 01:18:29 +00:00
openhands
33d6a11abf feat: three-tier view (Basic/Advanced/All), CriticalFields with ModelSelector, help links, and schema-driven sections
- Rewrite llm-settings.tsx with SettingsView = 'basic' | 'advanced' | 'all'
- Critical fields (llm.model, llm.api_key, llm.base_url) rendered with purpose-built
  components at the top: ModelSelector for model, SettingsInput for API key/base URL
- Generic schema-driven fields rendered below, excluding specially-rendered keys
- UI-only help link mapping (FIELD_HELP_LINKS) for API key guidance
- Add SETTINGS$ALL i18n key with translations for all supported languages
- Update sdk-settings-schema.ts with isFieldVisibleInView, inferInitialView,
  hasAdvancedSettings, hasMinorSettings, SPECIALLY_RENDERED_KEYS
- Fix no-continue lint error
- Add llm.base_url to mock schema and test fixtures
- Update all tests for three-tier view and CriticalFields rendering
- Remove search_api_key from has-advanced-settings-set.ts
- Merge main into branch

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 22:49:37 +00:00
openhands
71d5aa5aa8 Merge remote-tracking branch 'origin/main' into openhands/issue-2228-gui-settings-schema 2026-03-16 22:44:22 +00:00
openhands
90d2681e34 fix: handle git-based SDK deps in enterprise Docker build
Strip git-based openhands SDK dependencies from the exported
requirements.txt in the enterprise Dockerfile. These packages are
already installed via the base app image and cannot have their hashes
verified by pip when using git branch references.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 19:18:46 +00:00
openhands
565a5702c3 fix: pin Poetry >=2.3.0 in Dockerfile to match lockfile hash algorithm
Poetry 2.3.0 changed the content-hash algorithm to include dependency
groups. The Docker build cache had an older Poetry version that computed
a different hash, causing 'pyproject.toml changed significantly' errors.
Pinning >=2.3.0 ensures the Dockerfile installs a compatible version and
also busts the stale cache layer.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 19:04:55 +00:00
openhands
4b9097068d chore: regenerate lockfiles for Docker build compatibility
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 18:50:50 +00:00
openhands
c9a5834164 Merge main and resolve conflicts for SDK settings schema PR
- Resolved merge conflicts in 5 files keeping both PR and main changes
- Fixed TestLoadHooksFromWorkspace missing pending_message_service and
  max_num_conversations_per_sandbox constructor args
- Removed unused UUID import flagged by ruff

All 118 targeted tests pass, frontend builds cleanly, pre-commit checks pass.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-16 17:55:03 +00:00
openhands
19a089aa4b Merge main and fix settings schema CI
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-15 19:51:27 +00:00
openhands
918c44d164 Merge main and align settings schema with latest SDK
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-13 13:40:07 +00:00
openhands
e06e20a5ba fix: refresh SDK locks and settings schema coverage
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 17:55:59 +00:00
openhands
430ee1c9fd Point OpenHands dependencies at the SDK branch
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 01:32:57 +00:00
openhands
a03377698c Consume SDK AgentSettings schema in OpenHands
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-09 01:18:53 +00:00
openhands
9dab5b1bbf test: stub SDK schema in settings API coverage (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 21:43:35 +00:00
openhands
135d5fbd38 settings: fix schema-driven settings follow-ups (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 21:09:10 +00:00
openhands
ad615ebc8b settings: use generic sdk settings values in OpenHands
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 20:42:35 +00:00
openhands
424f6b30d1 settings: expose SDK settings schema to OpenHands (#2228)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-08 20:10:48 +00:00
100 changed files with 6959 additions and 6041 deletions

View File

@@ -22,7 +22,7 @@ ENV POETRY_NO_INTERACTION=1 \
RUN apt-get update -y \
&& apt-get install -y curl make git build-essential jq gettext \
&& python3 -m pip install poetry --break-system-packages
&& python3 -m pip install "poetry>=2.3.0" --break-system-packages
COPY pyproject.toml poetry.lock ./
RUN touch README.md

View File

@@ -33,7 +33,8 @@ RUN cd /tmp/enterprise && \
# Export only main dependencies with hashes for supply chain security
/app/.venv/bin/poetry export --only main -o requirements.txt && \
# Remove the local path dependency (openhands-ai is already in base image)
sed -i '/^-e /d; /openhands-ai/d' requirements.txt && \
# and git-based SDK dependencies (already installed via the base app image)
sed -i '/^-e /d; /openhands-ai/d; /^openhands-.*@ git+/d' requirements.txt && \
# Install pinned dependencies from lock file
/app/.venv/bin/pip install -r requirements.txt && \
# Cleanup - return to /app before removing /tmp/enterprise

View File

@@ -106,16 +106,18 @@ async def summarize_issue_solvability(
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
)
if user_settings.llm_api_key is None:
agent_settings = user_settings.agent_settings
llm_settings = agent_settings.llm
if llm_settings.api_key is None:
raise ValueError(
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
)
try:
llm_config = LLMConfig(
model=user_settings.llm_model,
api_key=user_settings.llm_api_key.get_secret_value(),
base_url=user_settings.llm_base_url,
model=llm_settings.model,
api_key=llm_settings.api_key.get_secret_value(),
base_url=llm_settings.base_url,
)
except ValidationError as e:
raise ValueError(

View File

@@ -0,0 +1,296 @@
"""Add agent_settings columns to enterprise settings tables.
Revision ID: 102
Revises: 101
Create Date: 2026-03-22 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '102'
down_revision: Union[str, None] = '101'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_EMPTY_JSON = sa.text("'{}'::json")
def upgrade() -> None:
op.add_column(
'user_settings',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'org_member',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.add_column(
'org',
sa.Column(
'agent_settings', sa.JSON(), nullable=False, server_default=_EMPTY_JSON
),
)
op.execute(
sa.text(
"""
UPDATE user_settings
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', max_iterations
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'llm.model', llm_model,
'llm.base_url', llm_base_url,
'max_iterations', max_iterations
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE org
SET agent_settings = jsonb_strip_nulls(
jsonb_build_object(
'schema_version', 1,
'agent', agent,
'llm.model', default_llm_model,
'llm.base_url', default_llm_base_url,
'verification.confirmation_mode', confirmation_mode,
'verification.security_analyzer', security_analyzer,
'condenser.enabled', enable_default_condenser,
'condenser.max_size', condenser_max_size,
'max_iterations', default_max_iterations,
'mcp_config', mcp_config
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
)::json
"""
)
)
op.execute(
sa.text(
"""
UPDATE user_settings AS us
SET llm_api_key_for_byor = om._llm_api_key_for_byor
FROM "user" AS u
JOIN org_member AS om
ON om.user_id = u.id
AND om.org_id = u.current_org_id
WHERE us.keycloak_user_id = u.id::text
AND us.llm_api_key_for_byor IS NULL
AND om._llm_api_key_for_byor IS NOT NULL
"""
)
)
op.execute(
sa.text(
"""
INSERT INTO user_settings (keycloak_user_id, llm_api_key_for_byor, agent_settings)
SELECT u.id::text, om._llm_api_key_for_byor, '{}'::json
FROM "user" AS u
JOIN org_member AS om
ON om.user_id = u.id
AND om.org_id = u.current_org_id
LEFT JOIN user_settings AS us
ON us.keycloak_user_id = u.id::text
WHERE us.id IS NULL
AND om._llm_api_key_for_byor IS NOT NULL
"""
)
)
op.alter_column('user_settings', 'agent_settings', server_default=None)
op.alter_column('org_member', 'agent_settings', server_default=None)
op.alter_column('org', 'agent_settings', server_default=None)
op.drop_column('user_settings', 'agent')
op.drop_column('user_settings', 'max_iterations')
op.drop_column('user_settings', 'security_analyzer')
op.drop_column('user_settings', 'confirmation_mode')
op.drop_column('user_settings', 'llm_model')
op.drop_column('user_settings', 'llm_base_url')
op.drop_column('user_settings', 'enable_default_condenser')
op.drop_column('user_settings', 'condenser_max_size')
op.drop_column('org_member', 'max_iterations')
op.drop_column('org_member', 'llm_model')
op.drop_column('org_member', '_llm_api_key_for_byor')
op.drop_column('org_member', 'llm_base_url')
op.drop_column('org', 'agent')
op.drop_column('org', 'default_max_iterations')
op.drop_column('org', 'security_analyzer')
op.drop_column('org', 'confirmation_mode')
op.drop_column('org', 'default_llm_model')
op.drop_column('org', 'default_llm_base_url')
op.drop_column('org', 'enable_default_condenser')
op.drop_column('org', 'mcp_config')
op.drop_column('org', 'condenser_max_size')
def downgrade() -> None:
op.add_column('user_settings', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('security_analyzer', sa.String(), nullable=True)
)
op.add_column(
'user_settings', sa.Column('confirmation_mode', sa.Boolean(), nullable=True)
)
op.add_column('user_settings', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'user_settings', sa.Column('llm_base_url', sa.String(), nullable=True)
)
op.add_column(
'user_settings',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column(
'user_settings', sa.Column('condenser_max_size', sa.Integer(), nullable=True)
)
op.add_column('org_member', sa.Column('llm_base_url', sa.String(), nullable=True))
op.add_column(
'org_member', sa.Column('_llm_api_key_for_byor', sa.String(), nullable=True)
)
op.add_column('org_member', sa.Column('llm_model', sa.String(), nullable=True))
op.add_column(
'org_member', sa.Column('max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org', sa.Column('agent', sa.String(), nullable=True))
op.add_column(
'org', sa.Column('default_max_iterations', sa.Integer(), nullable=True)
)
op.add_column('org', sa.Column('security_analyzer', sa.String(), nullable=True))
op.add_column('org', sa.Column('confirmation_mode', sa.Boolean(), nullable=True))
op.add_column('org', sa.Column('default_llm_model', sa.String(), nullable=True))
op.add_column('org', sa.Column('default_llm_base_url', sa.String(), nullable=True))
op.add_column(
'org',
sa.Column(
'enable_default_condenser',
sa.Boolean(),
nullable=False,
server_default=sa.true(),
),
)
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
op.execute(
sa.text(
"""
UPDATE user_settings
SET
agent = agent_settings ->> 'agent',
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
llm_model = agent_settings ->> 'llm.model',
llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member
SET
llm_model = agent_settings ->> 'llm.model',
llm_base_url = agent_settings ->> 'llm.base_url',
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer
"""
)
)
op.execute(
sa.text(
"""
UPDATE org
SET
agent = agent_settings ->> 'agent',
default_max_iterations =
NULLIF(agent_settings ->> 'max_iterations', '')::integer,
security_analyzer =
agent_settings ->> 'verification.security_analyzer',
confirmation_mode = CASE
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
ELSE NULL
END,
default_llm_model = agent_settings ->> 'llm.model',
default_llm_base_url = agent_settings ->> 'llm.base_url',
enable_default_condenser = CASE
WHEN agent_settings::jsonb ? 'condenser.enabled'
THEN (agent_settings ->> 'condenser.enabled')::boolean
ELSE TRUE
END,
mcp_config = agent_settings -> 'mcp_config',
condenser_max_size =
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
"""
)
)
op.execute(
sa.text(
"""
UPDATE org_member AS om
SET _llm_api_key_for_byor = us.llm_api_key_for_byor
FROM "user" AS u
JOIN user_settings AS us
ON us.keycloak_user_id = u.id::text
WHERE om.user_id = u.id
AND om.org_id = u.current_org_id
AND us.llm_api_key_for_byor IS NOT NULL
"""
)
)
op.drop_column('org', 'agent_settings')
op.drop_column('org_member', 'agent_settings')
op.drop_column('user_settings', 'agent_settings')

3721
enterprise/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,13 @@ from typing import cast
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, field_validator
from server.auth.saas_user_auth import SaasUserAuth
from sqlalchemy import select
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.org_service import OrgService
from storage.user_settings import UserSettings
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@@ -20,39 +21,29 @@ from openhands.server.user_auth.user_auth import AuthType
# Helper functions for BYOR API key management
async def get_byor_key_from_db(user_id: str) -> str | None:
"""Get the BYOR key from the database for a user."""
user = await UserStore.get_user_by_id(user_id)
if not user:
return None
async with a_session_maker() as session:
result = await session.execute(
select(UserSettings).filter(UserSettings.keycloak_user_id == user_id)
)
user_settings = result.scalars().first()
current_org_id = user.current_org_id
current_org_member: OrgMember | None = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
if current_org_member.llm_api_key_for_byor:
return current_org_member.llm_api_key_for_byor.get_secret_value()
return None
byor_key = user_settings.llm_api_key_for_byor_secret if user_settings else None
return byor_key.get_secret_value() if byor_key else None
async def store_byor_key_in_db(user_id: str, key: str) -> None:
"""Store the BYOR key in the database for a user."""
user = await UserStore.get_user_by_id(user_id)
if not user:
return None
async with a_session_maker() as session:
result = await session.execute(
select(UserSettings).filter(UserSettings.keycloak_user_id == user_id)
)
user_settings = result.scalars().first()
if not user_settings:
user_settings = UserSettings(keycloak_user_id=user_id)
session.add(user_settings)
current_org_id = user.current_org_id
current_org_member: OrgMember | None = None
for org_member in user.org_members:
if org_member.org_id == current_org_id:
current_org_member = org_member
break
if not current_org_member:
return None
current_org_member.llm_api_key_for_byor = key
await OrgMemberStore.update_org_member(current_org_member)
user_settings.llm_api_key_for_byor_secret = key
await session.commit()
async def generate_byor_key(user_id: str) -> str | None:

View File

@@ -1,4 +1,4 @@
from typing import Annotated
from typing import Annotated, Any
from pydantic import (
BaseModel,
@@ -8,6 +8,10 @@ from pydantic import (
StringConstraints,
field_validator,
)
from storage.agent_settings_utils import (
get_org_agent_settings,
get_org_member_agent_settings,
)
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
@@ -144,21 +148,13 @@ class OrgResponse(BaseModel):
contact_name: str
contact_email: str
conversation_expiration: int | None = None
agent: str | None = None
default_max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
remote_runtime_resource_factor: int | None = None
enable_default_condenser: bool = True
billing_margin: float | None = None
enable_proactive_conversation_starters: bool = True
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
org_version: int = 0
mcp_config: dict | None = None
agent_settings: dict[str, Any] = Field(default_factory=dict)
search_api_key: str | None = None
sandbox_api_key: str | None = None
max_budget_per_task: float | None = None
@@ -171,33 +167,14 @@ class OrgResponse(BaseModel):
def from_org(
cls, org: Org, credits: float | None = None, user_id: str | None = None
) -> 'OrgResponse':
"""Create an OrgResponse from an Org entity.
Args:
org: The organization entity to convert
credits: Optional credits value (defaults to None)
user_id: Optional user ID to determine if org is personal (defaults to None)
Returns:
OrgResponse: The response model instance
"""
"""Create an OrgResponse from an Org entity."""
return cls(
id=str(org.id),
name=org.name,
contact_name=org.contact_name,
contact_email=org.contact_email,
conversation_expiration=org.conversation_expiration,
agent=org.agent,
default_max_iterations=org.default_max_iterations,
security_analyzer=org.security_analyzer,
confirmation_mode=org.confirmation_mode,
default_llm_model=org.default_llm_model,
default_llm_api_key_for_byor=None,
default_llm_base_url=org.default_llm_base_url,
remote_runtime_resource_factor=org.remote_runtime_resource_factor,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
billing_margin=org.billing_margin,
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
if org.enable_proactive_conversation_starters is not None
@@ -205,7 +182,7 @@ class OrgResponse(BaseModel):
sandbox_base_container_image=org.sandbox_base_container_image,
sandbox_runtime_container_image=org.sandbox_runtime_container_image,
org_version=org.org_version if org.org_version is not None else 0,
mcp_config=org.mcp_config,
agent_settings=get_org_agent_settings(org),
search_api_key=None,
sandbox_api_key=None,
max_budget_per_task=org.max_budget_per_task,
@@ -227,7 +204,6 @@ class OrgPage(BaseModel):
class OrgUpdate(BaseModel):
"""Request model for updating an organization."""
# Basic organization information (any authenticated user can update)
name: Annotated[
str | None,
StringConstraints(strip_whitespace=True, min_length=1, max_length=255),
@@ -235,7 +211,6 @@ class OrgUpdate(BaseModel):
contact_name: str | None = None
contact_email: EmailStr | None = None
conversation_expiration: int | None = None
default_max_iterations: int | None = Field(default=None, gt=0)
remote_runtime_resource_factor: int | None = Field(default=None, gt=0)
billing_margin: float | None = Field(default=None, ge=0, le=1)
enable_proactive_conversation_starters: bool | None = None
@@ -245,31 +220,15 @@ class OrgUpdate(BaseModel):
max_budget_per_task: float | None = Field(default=None, gt=0)
enable_solvability_analysis: bool | None = None
v1_enabled: bool | None = None
# LLM settings (require admin/owner role)
default_llm_model: str | None = None
default_llm_api_key_for_byor: str | None = None
default_llm_base_url: str | None = None
search_api_key: str | None = None
security_analyzer: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
agent_settings: dict[str, Any] | None = None
class OrgLLMSettingsResponse(BaseModel):
"""Response model for organization LLM settings."""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
agent_settings: dict[str, Any] = Field(default_factory=dict)
search_api_key: str | None = None # Masked in response
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool = True
condenser_max_size: int | None = None
default_max_iterations: int | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str | None:
@@ -287,83 +246,45 @@ class OrgLLMSettingsResponse(BaseModel):
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
"""Create response from Org entity."""
return cls(
default_llm_model=org.default_llm_model,
default_llm_base_url=org.default_llm_base_url,
agent_settings=get_org_agent_settings(org),
search_api_key=cls._mask_key(org.search_api_key),
agent=org.agent,
confirmation_mode=org.confirmation_mode,
security_analyzer=org.security_analyzer,
enable_default_condenser=org.enable_default_condenser
if org.enable_default_condenser is not None
else True,
condenser_max_size=org.condenser_max_size,
default_max_iterations=org.default_max_iterations,
)
class OrgMemberLLMSettings(BaseModel):
"""LLM settings to propagate to organization members.
"""Shared LLM settings that may be propagated to organization members."""
Field names match OrgMember DB columns.
"""
llm_model: str | None = None
llm_base_url: str | None = None
max_iterations: int | None = None
agent_settings: dict[str, Any] | None = None
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
return any(
getattr(self, field) is not None for field in type(self).model_fields
)
class OrgLLMSettingsUpdate(BaseModel):
"""Request model for updating organization LLM settings.
"""Request model for updating organization LLM settings."""
Field names match Org DB columns exactly.
"""
default_llm_model: str | None = None
default_llm_base_url: str | None = None
agent_settings: dict[str, Any] | None = None
search_api_key: str | None = None
agent: str | None = None
confirmation_mode: bool | None = None
security_analyzer: str | None = None
enable_default_condenser: bool | None = None
condenser_max_size: int | None = Field(default=None, ge=20)
default_max_iterations: int | None = Field(default=None, gt=0)
llm_api_key: str | None = None
def has_updates(self) -> bool:
"""Check if any field is set (not None)."""
return any(getattr(self, field) is not None for field in self.model_fields)
return any(
getattr(self, field) is not None for field in type(self).model_fields
)
def apply_to_org(self, org: Org) -> None:
"""Apply non-None settings to the organization model.
Args:
org: Organization entity to update in place
"""
for field_name in self.model_fields:
value = getattr(self, field_name)
# Skip llm_api_key - it's only for member propagation, not org-level
if value is not None and field_name != 'llm_api_key':
setattr(org, field_name, value)
"""Apply non-None settings to the organization model."""
if self.search_api_key is not None:
org.search_api_key = self.search_api_key
def get_member_updates(self) -> OrgMemberLLMSettings | None:
"""Get updates that need to be propagated to org members.
Returns:
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
default_max_iterations → max_iterations, llm_api_key → llm_api_key
"""
member_settings = OrgMemberLLMSettings(
llm_model=self.default_llm_model,
llm_base_url=self.default_llm_base_url,
max_iterations=self.default_max_iterations,
llm_api_key=self.llm_api_key,
)
"""Get updates that need to be propagated to org members."""
member_settings = OrgMemberLLMSettings(llm_api_key=self.llm_api_key)
return member_settings if member_settings.has_updates() else None
@@ -400,18 +321,15 @@ class MeResponse(BaseModel):
email: str
role: str
llm_api_key: str
max_iterations: int | None = None
llm_model: str | None = None
llm_api_key_for_byor: str | None = None
llm_base_url: str | None = None
agent_settings: dict[str, Any] = Field(default_factory=dict)
status: str | None = None
@staticmethod
def _mask_key(secret: SecretStr | None) -> str:
def _mask_key(secret: str | SecretStr | None) -> str:
"""Mask an API key, showing only last 4 characters."""
if secret is None:
return ''
raw = secret.get_secret_value()
raw = secret.get_secret_value() if isinstance(secret, SecretStr) else secret
if not raw:
return ''
if len(raw) <= 4:
@@ -419,27 +337,20 @@ class MeResponse(BaseModel):
return '****' + raw[-4:]
@classmethod
def from_org_member(cls, member: OrgMember, role: Role, email: str) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email.
Args:
member: The OrgMember entity
role: The Role entity (provides role name)
email: The user's email address
Returns:
MeResponse with masked API keys
"""
def from_org_member(
cls,
member: OrgMember,
role: Role,
email: str,
) -> 'MeResponse':
"""Create a MeResponse from an OrgMember, Role, and user email."""
return cls(
org_id=str(member.org_id),
user_id=str(member.user_id),
email=email,
role=role.name,
llm_api_key=cls._mask_key(member.llm_api_key),
max_iterations=member.max_iterations,
llm_model=member.llm_model,
llm_api_key_for_byor=cls._mask_key(member.llm_api_key_for_byor) or None,
llm_base_url=member.llm_base_url,
agent_settings=get_org_member_agent_settings(member),
status=member.status,
)

View File

@@ -522,10 +522,9 @@ class SaasNestedConversationManager(ConversationManager):
mcp_config = await self._get_mcp_config(user_id)
if mcp_config:
# Merge with any MCP config from settings
if settings.mcp_config:
mcp_config = mcp_config.merge(settings.mcp_config)
# Check again since theoretically merge could return None.
settings_mcp_config = settings.to_legacy_mcp_config()
if settings_mcp_config:
mcp_config = mcp_config.merge(settings_mcp_config)
if mcp_config:
init_conversation['mcp_config'] = mcp_config.model_dump()
@@ -855,7 +854,7 @@ class SaasNestedConversationManager(ConversationManager):
user_id=user_id,
)
llm_registry.retry_listner = session._notify_on_llm_retry
agent_cls = settings.agent or self.config.default_agent
agent_cls = settings.agent_settings.agent or self.config.default_agent
agent_config = self.config.get_agent_config(agent_cls)
agent = Agent.get_cls(agent_cls)(agent_config, llm_registry)

View File

@@ -371,9 +371,9 @@ class OrgInvitationService:
raise InvitationInvalidError('Organization not found')
# Step 5: Add user to organization with inherited org LLM settings
# Get the llm_api_key as string (it's SecretStr | None in Settings)
llm_api_key_secret = settings.get_secret_agent_setting('llm.api_key')
llm_api_key = (
settings.llm_api_key.get_secret_value() if settings.llm_api_key else ''
llm_api_key_secret.get_secret_value() if llm_api_key_secret else ''
)
await OrgMemberStore.add_user_to_org(
@@ -382,9 +382,7 @@ class OrgInvitationService:
role_id=invitation.role_id,
llm_api_key=llm_api_key,
status='active',
llm_model=org.default_llm_model,
llm_base_url=org.default_llm_base_url,
max_iterations=org.default_max_iterations,
agent_settings=OrgStore.get_agent_settings_from_org(org),
)
# Step 6: Mark invitation as accepted

View File

@@ -55,7 +55,6 @@ class OrgMemberService:
if role is None:
raise RoleNotFoundError(org_member.role_id)
# Get user email
user = await UserStore.get_user_by_id(str(user_id))
email = user.email if user and user.email else ''

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any, Mapping
from storage.org import Org
from storage.org_member import OrgMember
_SCHEMA_VERSION = 1
def ensure_schema_version(agent_settings: Mapping[str, Any] | None) -> dict[str, Any]:
normalized = dict(agent_settings or {})
if normalized and 'schema_version' not in normalized:
normalized['schema_version'] = _SCHEMA_VERSION
return normalized
def merge_agent_settings(
base: Mapping[str, Any] | None,
updates: Mapping[str, Any] | None,
) -> dict[str, Any]:
merged = dict(base or {})
for key, value in (updates or {}).items():
if key == 'schema_version':
continue
if value is None:
merged.pop(key, None)
else:
merged[key] = value
return ensure_schema_version(merged)
def get_org_agent_settings(org: Org) -> dict[str, Any]:
return ensure_schema_version(dict(getattr(org, 'agent_settings', {}) or {}))
def get_org_member_agent_settings(org_member: OrgMember) -> dict[str, Any]:
return ensure_schema_version(dict(getattr(org_member, 'agent_settings', {}) or {}))

View File

@@ -216,11 +216,11 @@ class LiteLlmManager:
None,
)
oss_settings.agent = 'CodeActAgent'
oss_settings.set_agent_setting('agent', 'CodeActAgent')
# Use the model corresponding to the current user settings version
oss_settings.llm_model = get_default_litellm_model()
oss_settings.llm_api_key = SecretStr(key)
oss_settings.llm_base_url = LITE_LLM_API_URL
oss_settings.set_agent_setting('llm.model', get_default_litellm_model())
oss_settings.set_agent_setting('llm.api_key', SecretStr(key))
oss_settings.set_agent_setting('llm.base_url', LITE_LLM_API_URL)
return oss_settings
@staticmethod
@@ -354,10 +354,15 @@ class LiteLlmManager:
# Check if the database key exists in LiteLLM
# If not, generate a new key to prevent verification failures later
db_key = None
llm_base_url = (
user_settings.agent_settings.get('llm.base_url')
if user_settings and user_settings.agent_settings
else None
)
if (
user_settings
and user_settings.llm_api_key
and user_settings.llm_base_url == LITE_LLM_API_URL
and llm_base_url == LITE_LLM_API_URL
):
db_key = user_settings.llm_api_key
if hasattr(db_key, 'get_secret_value'):
@@ -393,7 +398,7 @@ class LiteLlmManager:
)
# Update user_settings with the new key so it gets stored in org_member
user_settings.llm_api_key = SecretStr(new_key)
user_settings.llm_api_key_for_byor = SecretStr(new_key)
user_settings.llm_api_key_for_byor_secret = SecretStr(new_key)
logger.info(
'LiteLlmManager:migrate_lite_llm_entries:complete',

View File

@@ -21,14 +21,7 @@ class Org(Base): # type: ignore
name = Column(String, nullable=False, unique=True)
contact_name = Column(String, nullable=True)
contact_email = Column(String, nullable=True)
agent = Column(String, nullable=True)
default_max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
default_llm_model = Column(String, nullable=True)
default_llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
enable_proactive_conversation_starters = Column(
Boolean, nullable=False, default=True
@@ -36,7 +29,7 @@ class Org(Base): # type: ignore
sandbox_base_container_image = Column(String, nullable=True)
sandbox_runtime_container_image = Column(String, nullable=True)
org_version = Column(Integer, nullable=False, default=0)
mcp_config = Column(JSON, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
# encrypted column, don't set directly, set without the underscore
_search_api_key = Column(String, nullable=True)
# encrypted column, don't set directly, set without the underscore
@@ -45,7 +38,6 @@ class Org(Base): # type: ignore
enable_solvability_analysis = Column(Boolean, nullable=True, default=False)
v1_enabled = Column(Boolean, nullable=True)
conversation_expiration = Column(Integer, nullable=True)
condenser_max_size = Column(Integer, nullable=True)
byor_export_enabled = Column(Boolean, nullable=False, default=False)
sandbox_grouping_strategy = Column(String, nullable=True)

View File

@@ -13,6 +13,7 @@ from server.constants import (
from server.routes.org_models import OrgAppSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.agent_settings_utils import get_org_agent_settings, merge_agent_settings
from storage.org import Org
from storage.user import User
@@ -65,8 +66,13 @@ class OrgAppSettingsStore:
"""
if org.org_version < ORG_SETTINGS_VERSION:
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
org.llm_base_url = LITE_LLM_API_URL
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
{
'llm.model': get_default_litellm_model(),
'llm.base_url': LITE_LLM_API_URL,
},
)
await self.db_session.flush()
await self.db_session.refresh(org)

View File

@@ -9,6 +9,7 @@ from uuid import UUID
from server.routes.org_models import OrgLLMSettingsUpdate
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from storage.agent_settings_utils import get_org_agent_settings, merge_agent_settings
from storage.org import Org
from storage.org_member_store import OrgMemberStore
from storage.user import User
@@ -67,8 +68,12 @@ class OrgLLMSettingsStore:
if not org:
return None
# Apply updates to org (excludes llm_api_key which is member-only)
update_data.apply_to_org(org)
if update_data.agent_settings is not None:
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
update_data.agent_settings,
)
# Propagate relevant settings to all org members
member_updates = update_data.get_member_updates()

View File

@@ -1,6 +1,4 @@
"""
SQLAlchemy model for Organization-Member relationship.
"""
"""SQLAlchemy model for organization-member relationships."""
from pydantic import SecretStr
from sqlalchemy import JSON, UUID, Column, ForeignKey, Integer, String
@@ -18,51 +16,30 @@ class OrgMember(Base): # type: ignore
user_id = Column(UUID(as_uuid=True), ForeignKey('user.id'), primary_key=True)
role_id = Column(Integer, ForeignKey('role.id'), nullable=False)
_llm_api_key = Column(String, nullable=False)
max_iterations = Column(Integer, nullable=True)
llm_model = Column(String, nullable=True)
_llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
status = Column(String, nullable=True)
mcp_config = Column(JSON, nullable=True)
# Relationships
org = relationship('Org', back_populates='org_members')
user = relationship('User', back_populates='org_members')
role = relationship('Role', back_populates='org_members')
def __init__(self, **kwargs):
# Handle known SQLAlchemy columns directly
for key in list(kwargs):
if hasattr(self.__class__, key):
setattr(self, key, kwargs.pop(key))
# Handle custom property-style fields
if 'llm_api_key' in kwargs:
self.llm_api_key = kwargs.pop('llm_api_key')
if 'llm_api_key_for_byor' in kwargs:
self.llm_api_key_for_byor = kwargs.pop('llm_api_key_for_byor')
if kwargs:
raise TypeError(f'Unexpected keyword arguments: {list(kwargs.keys())}')
@property
def llm_api_key(self) -> SecretStr:
decrypted = decrypt_value(self._llm_api_key)
return SecretStr(decrypted)
return SecretStr(decrypt_value(self._llm_api_key))
@llm_api_key.setter
def llm_api_key(self, value: str | SecretStr):
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self._llm_api_key = encrypt_value(raw)
@property
def llm_api_key_for_byor(self) -> SecretStr | None:
if self._llm_api_key_for_byor:
decrypted = decrypt_value(self._llm_api_key_for_byor)
return SecretStr(decrypted)
return None
@llm_api_key_for_byor.setter
def llm_api_key_for_byor(self, value: str | SecretStr | None):
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self._llm_api_key_for_byor = encrypt_value(raw) if raw else None

View File

@@ -6,11 +6,14 @@ from typing import Optional
from uuid import UUID
from server.routes.org_models import OrgMemberLLMSettings
from sqlalchemy import func, select, update
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload
from storage.agent_settings_utils import (
get_org_member_agent_settings,
merge_agent_settings,
)
from storage.database import a_session_maker
from storage.encrypt_utils import encrypt_value
from storage.org_member import OrgMember
from storage.user import User
from storage.user_settings import UserSettings
@@ -28,11 +31,11 @@ class OrgMemberStore:
role_id: int,
llm_api_key: str,
status: Optional[str] = None,
llm_model: Optional[str] = None,
llm_base_url: Optional[str] = None,
max_iterations: Optional[int] = None,
agent_settings: Optional[dict] = None,
) -> OrgMember:
"""Add a user to an organization with a specific role."""
agent_settings = dict(agent_settings or {})
async with a_session_maker() as session:
org_member = OrgMember(
org_id=org_id,
@@ -40,9 +43,7 @@ class OrgMemberStore:
role_id=role_id,
llm_api_key=llm_api_key,
status=status,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
agent_settings=agent_settings,
)
session.add(org_member)
await session.commit()
@@ -148,23 +149,28 @@ class OrgMemberStore:
await session.commit()
return True
@staticmethod
def get_agent_settings_from_org_member(org_member: OrgMember) -> dict[str, object]:
return get_org_member_agent_settings(org_member)
@staticmethod
def get_kwargs_from_settings(settings: Settings):
kwargs = {
normalized: getattr(settings, normalized)
for c in OrgMember.__table__.columns
if (normalized := c.name.lstrip('_')) and hasattr(settings, normalized)
return {
'llm_api_key': settings.get_secret_agent_setting('llm.api_key'),
'agent_settings': settings.normalized_agent_settings(
strip_secret_values=True
),
}
return kwargs
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {
normalized: getattr(user_settings, normalized)
for c in OrgMember.__table__.columns
if (normalized := c.name.lstrip('_')) and hasattr(user_settings, normalized)
agent_settings = dict(user_settings.agent_settings or {})
if agent_settings and 'schema_version' not in agent_settings:
agent_settings['schema_version'] = 1
return {
'llm_api_key': user_settings.llm_api_key,
'agent_settings': agent_settings,
}
return kwargs
@staticmethod
async def get_org_members_count(
@@ -244,21 +250,34 @@ class OrgMemberStore:
org_id: UUID,
member_settings: OrgMemberLLMSettings,
) -> None:
"""Update LLM settings for all members of an organization.
"""Update shared LLM settings for all members of an organization.
Args:
session: Database session (passed from caller for transaction)
org_id: Organization ID
member_settings: Typed LLM settings to apply to all members
member_settings: Shared settings to apply to all members
"""
# Build update values from non-None fields
values = member_settings.model_dump(exclude_none=True)
if not values:
return
# Handle encrypted llm_api_key field - map to _llm_api_key column with encryption
if 'llm_api_key' in values:
raw_key = values.pop('llm_api_key')
values['_llm_api_key'] = encrypt_value(raw_key)
result = await session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
)
org_members = list(result.scalars().all())
if values:
stmt = update(OrgMember).where(OrgMember.org_id == org_id).values(**values)
await session.execute(stmt)
raw_key = values.pop('llm_api_key', None)
agent_settings_updates = values.pop('agent_settings', None)
for org_member in org_members:
if raw_key is not None:
org_member.llm_api_key = raw_key
if agent_settings_updates is not None:
org_member.agent_settings = merge_agent_settings(
get_org_member_agent_settings(org_member),
agent_settings_updates,
)
for key, value in values.items():
setattr(org_member, key, value)

View File

@@ -113,7 +113,10 @@ class OrgService:
contact_name=contact_name,
contact_email=contact_email,
org_version=ORG_SETTINGS_VERSION,
default_llm_model=get_default_litellm_model(),
agent_settings={
'schema_version': 1,
'llm.model': get_default_litellm_model(),
},
)
@staticmethod
@@ -467,42 +470,6 @@ class OrgService:
)
return False
@staticmethod
def _get_llm_settings_fields() -> set[str]:
"""
Get the set of organization fields that are considered LLM settings
and require admin/owner role to update.
Returns:
set[str]: Set of field names that require elevated permissions
"""
return {
'default_llm_model',
'default_llm_api_key_for_byor',
'default_llm_base_url',
'search_api_key',
'security_analyzer',
'agent',
'confirmation_mode',
'enable_default_condenser',
'condenser_max_size',
}
@staticmethod
def _has_llm_settings_updates(update_data: OrgUpdate) -> set[str]:
"""
Check if the update contains any LLM settings fields.
Args:
update_data: The organization update data
Returns:
set[str]: Set of LLM fields being updated (empty if none)
"""
llm_fields = OrgService._get_llm_settings_fields()
update_dict = update_data.model_dump(exclude_none=True)
return llm_fields.intersection(update_dict.keys())
@staticmethod
async def update_org_with_permissions(
org_id: UUID,
@@ -571,33 +538,6 @@ class OrgService:
)
raise OrgNameExistsError(update_data.name)
# Check if update contains any LLM settings
llm_fields_being_updated = OrgService._has_llm_settings_updates(update_data)
if llm_fields_being_updated:
# Verify user has admin or owner role
has_permission = await OrgService.has_admin_or_owner_role(user_id, org_id)
if not has_permission:
logger.warning(
'User attempted to update LLM settings without permission',
extra={
'user_id': user_id,
'org_id': str(org_id),
'attempted_fields': list(llm_fields_being_updated),
},
)
raise PermissionError(
'Admin or owner role required to update LLM settings'
)
logger.debug(
'User has permission to update LLM settings',
extra={
'user_id': user_id,
'org_id': str(org_id),
'llm_fields': list(llm_fields_being_updated),
},
)
# Convert to dict for OrgStore (excluding None values)
update_dict = update_data.model_dump(exclude_none=True)
if not update_dict:
@@ -607,6 +547,24 @@ class OrgService:
)
return existing_org
restricted_fields = {'agent_settings', 'search_api_key', 'sandbox_api_key'}
if restricted_fields.intersection(
update_dict
) and not await OrgService.has_admin_or_owner_role(user_id, org_id):
logger.warning(
'Insufficient role for restricted organization settings update',
extra={
'user_id': user_id,
'org_id': str(org_id),
'restricted_fields': sorted(
restricted_fields.intersection(update_dict)
),
},
)
raise PermissionError(
'Admin or owner role required to update organization agent settings'
)
# Perform the update
try:
updated_org = await OrgStore.update_org(org_id, update_dict)

View File

@@ -14,6 +14,10 @@ from server.constants import (
from server.routes.org_models import OrgLLMSettingsUpdate, OrphanedUserError
from sqlalchemy import select, text
from sqlalchemy.orm import joinedload
from storage.agent_settings_utils import (
get_org_agent_settings,
merge_agent_settings,
)
from storage.database import a_session_maker
from storage.lite_llm_manager import LiteLlmManager
from storage.org import Org
@@ -24,10 +28,32 @@ from storage.user_settings import UserSettings
from openhands.core.logger import openhands_logger as logger
from openhands.storage.data_models.settings import Settings
_ORG_SETTINGS_EXCLUDED_FIELDS = {
'id',
'name',
'contact_name',
'contact_email',
'org_version',
'agent_settings',
}
_ORG_SETTINGS_FIELDS = {
normalized
for column in Org.__table__.columns
if (normalized := column.name.lstrip('_')) not in _ORG_SETTINGS_EXCLUDED_FIELDS
}
class OrgStore:
"""Store for managing organizations."""
@staticmethod
def get_agent_settings_from_org(org: Org) -> dict[str, object]:
return get_org_agent_settings(org)
@staticmethod
def sync_agent_settings(org: Org) -> None:
org.agent_settings = get_org_agent_settings(org)
@staticmethod
async def create_org(
kwargs: dict,
@@ -36,7 +62,13 @@ class OrgStore:
async with a_session_maker() as session:
org = Org(**kwargs)
org.org_version = ORG_SETTINGS_VERSION
org.default_llm_model = get_default_litellm_model()
org.agent_settings = merge_agent_settings(
org.agent_settings,
{
'llm.model': get_org_agent_settings(org).get('llm.model')
or get_default_litellm_model()
},
)
if org.v1_enabled is None:
org.v1_enabled = DEFAULT_V1_ENABLED
session.add(org)
@@ -92,8 +124,10 @@ class OrgStore:
org.id,
{
'org_version': ORG_SETTINGS_VERSION,
'default_llm_model': get_default_litellm_model(),
'llm_base_url': LITE_LLM_API_URL,
'agent_settings': {
'llm.model': get_default_litellm_model(),
'llm.base_url': LITE_LLM_API_URL,
},
},
)
return org
@@ -180,57 +214,45 @@ class OrgStore:
if 'id' in kwargs:
kwargs.pop('id')
agent_settings_updates = kwargs.pop('agent_settings', None)
for key, value in kwargs.items():
if hasattr(org, key):
setattr(org, key, value)
if agent_settings_updates is not None:
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
agent_settings_updates,
)
await session.commit()
await session.refresh(org)
return org
@staticmethod
def get_kwargs_from_settings(settings: Settings):
kwargs = {}
for c in Org.__table__.columns:
# Normalize for lookup
normalized = (
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
)
if not hasattr(settings, normalized):
continue
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
key = c.name
if key.startswith('_'):
key = key[1:] # remove only the very first leading underscore
kwargs[key] = getattr(settings, normalized)
kwargs = {
field: getattr(settings, field)
for field in _ORG_SETTINGS_FIELDS
if hasattr(settings, field)
}
kwargs['agent_settings'] = settings.normalized_agent_settings(
strip_secret_values=True
)
return kwargs
@staticmethod
def get_kwargs_from_user_settings(user_settings: UserSettings):
kwargs = {}
for c in Org.__table__.columns:
# Normalize for lookup
normalized = (
c.name.removeprefix('_default_').removeprefix('default_').lstrip('_')
)
if not hasattr(user_settings, normalized):
continue
# ---- FIX: Output key should drop *only* leading "_" but preserve "default" ----
key = c.name
if key.startswith('_'):
key = key[1:] # remove only the very first leading underscore
kwargs[key] = getattr(user_settings, normalized)
kwargs = {
field: getattr(user_settings, field)
for field in _ORG_SETTINGS_FIELDS
if hasattr(user_settings, field)
}
kwargs['org_version'] = user_settings.user_version
kwargs['agent_settings'] = (
user_settings.to_settings().normalized_agent_settings(strip_secret_values=True)
)
return kwargs
@staticmethod
@@ -431,8 +453,12 @@ class OrgStore:
if not org:
return None
# Apply updates to org
llm_settings.apply_to_org(org)
if llm_settings.agent_settings is not None:
org.agent_settings = merge_agent_settings(
get_org_agent_settings(org),
llm_settings.agent_settings,
)
# Propagate relevant settings to all org members
member_updates = llm_settings.get_member_updates()

View File

@@ -1,12 +1,8 @@
from __future__ import annotations
import binascii
import hashlib
import uuid
from base64 import b64decode, b64encode
from dataclasses import dataclass
from cryptography.fernet import Fernet
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.constants import LITE_LLM_API_URL
@@ -14,10 +10,10 @@ from server.logger import logger
from sqlalchemy import select, update
from sqlalchemy.orm import joinedload
from storage.database import a_session_maker
from storage.encrypt_utils import encrypt_value
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
from storage.org import Org
from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.org_store import OrgStore
from storage.user import User
from storage.user_settings import UserSettings
@@ -33,7 +29,6 @@ from openhands.utils.llm import is_openhands_model
class SaasSettingsStore(SettingsStore):
user_id: str
config: OpenHandsConfig
ENCRYPT_VALUES = ['llm_api_key', 'llm_api_key_for_byor', 'search_api_key']
async def _get_user_settings_by_keycloak_id_async(
self, keycloak_user_id: str, session=None
@@ -69,6 +64,33 @@ class SaasSettingsStore(SettingsStore):
)
return result.scalars().first()
async def _persist_agent_settings_async(
self, org_id: uuid.UUID, agent_settings: dict
) -> None:
async with a_session_maker() as session:
stmt = (
update(OrgMember)
.where(
OrgMember.org_id == org_id,
OrgMember.user_id == uuid.UUID(self.user_id),
)
.values(agent_settings=agent_settings)
)
await session.execute(stmt)
await session.commit()
async def _persist_org_agent_settings_async(
self, org_id: uuid.UUID, agent_settings: dict
) -> None:
async with a_session_maker() as session:
stmt = (
update(Org)
.where(Org.id == org_id)
.values(agent_settings=agent_settings)
)
await session.execute(stmt)
await session.commit()
async def load(self) -> Settings | None:
user = await UserStore.get_user_by_id(self.user_id)
if not user:
@@ -89,6 +111,11 @@ class SaasSettingsStore(SettingsStore):
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
)
return None
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
member_agent_settings = OrgMemberStore.get_agent_settings_from_org_member(
org_member
)
kwargs = {
**{
normalized: getattr(org, c.name)
@@ -107,17 +134,13 @@ class SaasSettingsStore(SettingsStore):
},
}
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
# MCP config is user-specific (stored on org_member, not org)
if org_member.mcp_config is not None:
kwargs['mcp_config'] = org_member.mcp_config
effective_member_agent_settings = {
**org_agent_settings,
**member_agent_settings,
}
kwargs['agent_settings'] = effective_member_agent_settings
if org.v1_enabled is None:
kwargs['v1_enabled'] = True
# Apply default if sandbox_grouping_strategy is None in the database
@@ -125,6 +148,12 @@ class SaasSettingsStore(SettingsStore):
kwargs.pop('sandbox_grouping_strategy', None)
settings = Settings(**kwargs)
if org_agent_settings != (org.agent_settings or {}):
await self._persist_org_agent_settings_async(org_id, org_agent_settings)
if effective_member_agent_settings != (org_member.agent_settings or {}):
await self._persist_agent_settings_async(
org_id, effective_member_agent_settings
)
return settings
async def store(self, item: Settings):
@@ -181,56 +210,52 @@ class SaasSettingsStore(SettingsStore):
)
return None
llm_model = item.get_agent_setting('llm.model')
llm_base_url = item.get_agent_setting('llm.base_url')
uses_managed_llm_key = not llm_base_url or llm_base_url == LITE_LLM_API_URL
# Check if we need to generate an LLM key.
if not item.llm_base_url or item.llm_base_url == LITE_LLM_API_URL:
if uses_managed_llm_key:
await self._ensure_api_key(
item, str(org_id), openhands_type=is_openhands_model(item.llm_model)
item, str(org_id), openhands_type=is_openhands_model(llm_model)
)
normalized_agent_settings = item.normalized_agent_settings(
strip_secret_values=True
)
shared_agent_settings = {
key: value
for key, value in normalized_agent_settings.items()
if key not in {'llm.api_key', 'mcp_config'}
}
current_member_llm_api_key = item.get_secret_agent_setting('llm.api_key')
shared_llm_api_key = (
current_member_llm_api_key.get_secret_value()
if current_member_llm_api_key and not uses_managed_llm_key
else None
)
kwargs = item.model_dump(context={'expose_secrets': True})
for model in (user, org, org_member):
for key, value in kwargs.items():
# Skip mcp_config for org - it should only be stored on org_member (user-specific)
if key == 'mcp_config' and model is org:
continue
if hasattr(model, key):
setattr(model, key, value)
kwargs.pop('agent_settings', None)
for key, value in kwargs.items():
if hasattr(user, key):
setattr(user, key, value)
if key != 'mcp_config' and hasattr(org, key):
setattr(org, key, value)
if key == 'mcp_config' and hasattr(org_member, key):
setattr(org_member, key, value)
# Map Settings fields to Org fields with 'default_' prefix
# The generic loop above doesn't update these because Org uses
# 'default_llm_model' not 'llm_model', etc.
# Use exclude_unset to only update explicitly-set fields (allows clearing with null)
settings_data = item.model_dump(exclude_unset=True)
if 'llm_model' in settings_data:
org.default_llm_model = settings_data['llm_model']
if 'llm_base_url' in settings_data:
org.default_llm_base_url = settings_data['llm_base_url']
if 'max_iterations' in settings_data:
org.default_max_iterations = settings_data['max_iterations']
org.agent_settings = shared_agent_settings
# Propagate LLM settings to all org members
# This ensures all members see the same LLM configuration when an admin saves
# Note: Concurrent saves by multiple admins will result in last-write-wins.
# Consider adding optimistic locking if this becomes a problem.
member_update_values: dict = {}
if item.llm_model is not None:
member_update_values['llm_model'] = item.llm_model
if item.llm_base_url is not None:
member_update_values['llm_base_url'] = item.llm_base_url
if item.max_iterations is not None:
member_update_values['max_iterations'] = item.max_iterations
if item.llm_api_key is not None:
member_update_values['_llm_api_key'] = encrypt_value(
item.llm_api_key.get_secret_value()
)
result = await session.execute(select(OrgMember).filter(OrgMember.org_id == org_id))
org_members = list(result.scalars().all())
for member in org_members:
member.agent_settings = dict(shared_agent_settings)
if shared_llm_api_key is not None:
member.llm_api_key = shared_llm_api_key
if member_update_values:
stmt = (
update(OrgMember)
.where(OrgMember.org_id == org_id)
.values(**member_update_values)
)
await session.execute(stmt)
if current_member_llm_api_key is not None:
org_member.llm_api_key = current_member_llm_api_key
await session.commit()
@@ -243,52 +268,6 @@ class SaasSettingsStore(SettingsStore):
logger.debug(f'saas_settings_store.get_instance::{user_id}')
return SaasSettingsStore(user_id, config)
def _should_encrypt(self, key):
return key in self.ENCRYPT_VALUES
def _decrypt_kwargs(self, kwargs: dict):
fernet = self._fernet()
for key, value in kwargs.items():
try:
if value is None:
continue
if self._should_encrypt(key):
if isinstance(value, SecretStr):
value = fernet.decrypt(
b64decode(value.get_secret_value().encode())
).decode()
else:
value = fernet.decrypt(b64decode(value.encode())).decode()
kwargs[key] = value
except binascii.Error:
pass # Key is in legacy format...
def _encrypt_kwargs(self, kwargs: dict):
fernet = self._fernet()
for key, value in kwargs.items():
if value is None:
continue
if isinstance(value, dict):
self._encrypt_kwargs(value)
continue
if self._should_encrypt(key):
if isinstance(value, SecretStr):
value = b64encode(
fernet.encrypt(value.get_secret_value().encode())
).decode()
else:
value = b64encode(fernet.encrypt(value.encode())).decode()
kwargs[key] = value
def _fernet(self):
if not self.config.jwt_secret:
raise ValueError('jwt_secret must be defined on config')
jwt_secret = self.config.jwt_secret.get_secret_value()
fernet_key = b64encode(hashlib.sha256(jwt_secret.encode()).digest())
return Fernet(fernet_key)
async def _ensure_api_key(
self, item: Settings, org_id: str, openhands_type: bool = False
) -> None:
@@ -298,9 +277,11 @@ class SaasSettingsStore(SettingsStore):
is valid in LiteLLM. If valid, reuses it. Otherwise, generates a new key.
"""
llm_api_key = item.get_secret_agent_setting('llm.api_key')
# First, check if our current key is valid
if item.llm_api_key and not await LiteLlmManager.verify_existing_key(
item.llm_api_key.get_secret_value(),
if llm_api_key and not await LiteLlmManager.verify_existing_key(
llm_api_key.get_secret_value(),
self.user_id,
org_id,
openhands_type=openhands_type,
@@ -323,7 +304,7 @@ class SaasSettingsStore(SettingsStore):
None,
)
item.llm_api_key = SecretStr(generated_key)
item.set_agent_setting('llm.api_key', SecretStr(generated_key))
logger.info(
'saas_settings_store:store:generated_openhands_key',
extra={'user_id': self.user_id},

View File

@@ -1,6 +1,10 @@
from __future__ import annotations
from pydantic import SecretStr
from server.constants import DEFAULT_BILLING_MARGIN
from sqlalchemy import JSON, Boolean, Column, DateTime, Float, Identity, Integer, String
from storage.base import Base
from storage.encrypt_utils import decrypt_legacy_value, encrypt_legacy_value
class UserSettings(Base): # type: ignore
@@ -8,17 +12,9 @@ class UserSettings(Base): # type: ignore
id = Column(Integer, Identity(), primary_key=True)
keycloak_user_id = Column(String, nullable=True, index=True)
language = Column(String, nullable=True)
agent = Column(String, nullable=True)
max_iterations = Column(Integer, nullable=True)
security_analyzer = Column(String, nullable=True)
confirmation_mode = Column(Boolean, nullable=True, default=False)
llm_model = Column(String, nullable=True)
llm_api_key = Column(String, nullable=True)
llm_api_key_for_byor = Column(String, nullable=True)
llm_base_url = Column(String, nullable=True)
remote_runtime_resource_factor = Column(Integer, nullable=True)
enable_default_condenser = Column(Boolean, nullable=False, default=True)
condenser_max_size = Column(Integer, nullable=True)
user_consents_to_analytics = Column(Boolean, nullable=True)
billing_margin = Column(Float, nullable=True, default=DEFAULT_BILLING_MARGIN)
enable_sound_notifications = Column(Boolean, nullable=True, default=False)
@@ -41,6 +37,31 @@ class UserSettings(Base): # type: ignore
git_user_name = Column(String, nullable=True)
git_user_email = Column(String, nullable=True)
v1_enabled = Column(Boolean, nullable=True)
agent_settings = Column(JSON, nullable=False, default=dict)
@property
def llm_api_key_for_byor_secret(self) -> SecretStr | None:
raw = self.llm_api_key_for_byor
if not raw:
return None
try:
return SecretStr(decrypt_legacy_value(raw))
except Exception:
return SecretStr(raw)
@llm_api_key_for_byor_secret.setter
def llm_api_key_for_byor_secret(self, value: str | SecretStr | None) -> None:
if value is None:
self.llm_api_key_for_byor = None
return
raw = value.get_secret_value() if isinstance(value, SecretStr) else value
self.llm_api_key_for_byor = encrypt_legacy_value(raw)
already_migrated = Column(
Boolean, nullable=True, default=False
) # False = not migrated, True = migrated
def to_settings(self):
from openhands.storage.data_models.settings import Settings
return Settings(agent_settings=dict(self.agent_settings or {}))

View File

@@ -91,9 +91,6 @@ class UserStore:
from storage.org_member_store import OrgMemberStore
org_member_kwargs = OrgMemberStore.get_kwargs_from_settings(settings)
# avoid setting org member llm fields to use org defaults on user creation
del org_member_kwargs['llm_model']
del org_member_kwargs['llm_base_url']
org_member = OrgMember(
org_id=org.id,
user_id=user.id,
@@ -233,10 +230,13 @@ class UserStore:
org_kwargs = OrgStore.get_kwargs_from_user_settings(decrypted_user_settings)
org_kwargs.pop('id', None)
# if user has custom settings, set org defaults to current version
# If the user has custom settings, keep the org defaults minimal.
if custom_settings:
org_kwargs['default_llm_model'] = get_default_litellm_model()
org_kwargs['llm_base_url'] = LITE_LLM_API_URL
org_kwargs['agent_settings'] = {
'schema_version': 1,
'llm.model': get_default_litellm_model(),
'llm.base_url': LITE_LLM_API_URL,
}
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
for key, value in org_kwargs.items():
@@ -276,12 +276,10 @@ class UserStore:
org_member_kwargs = OrgMemberStore.get_kwargs_from_user_settings(
decrypted_user_settings
)
# if the user did not have custom settings in the old model,
# then use the org defaults by not setting org_member fields
if not custom_settings:
del org_member_kwargs['llm_model']
del org_member_kwargs['llm_base_url']
org_member_kwargs['agent_settings'] = (
OrgStore.get_agent_settings_from_org(org)
)
org_member = OrgMember(
org_id=org.id,
@@ -467,13 +465,6 @@ class UserStore:
user_settings.llm_api_key = encrypt_legacy_value(
org_member.llm_api_key.get_secret_value()
)
if (
org_member.llm_api_key_for_byor
and org_member.llm_api_key_for_byor.get_secret_value()
):
user_settings.llm_api_key_for_byor = encrypt_legacy_value(
org_member.llm_api_key_for_byor.get_secret_value()
)
logger.info(
'user_store:downgrade_user:updated_user_settings_from_org_member',
extra={'user_id': user_id},
@@ -951,44 +942,20 @@ class UserStore:
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',
}
from storage.org_member_store import OrgMemberStore
from storage.org_store import OrgStore
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
member_agent_settings = OrgMemberStore.get_agent_settings_from_org_member(
org_member
)
org_agent_settings = OrgStore.get_agent_settings_from_org(org)
agent_settings = {**org_agent_settings, **member_agent_settings}
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,
@@ -997,18 +964,12 @@ class UserStore:
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,
@@ -1018,7 +979,8 @@ class UserStore:
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,
sandbox_grouping_strategy=org.sandbox_grouping_strategy,
agent_settings=agent_settings,
already_migrated=False,
)
@@ -1036,16 +998,17 @@ class UserStore:
Returns:
True if user has custom settings, False if using old defaults
"""
# Normalize values
user_model = (
user_settings.llm_model.strip() or None if user_settings.llm_model else None
persisted_agent_settings = user_settings.agent_settings or {}
user_model = persisted_agent_settings.get('llm.model') or getattr(
user_settings, 'llm_model', None
)
user_base_url = (
user_settings.llm_base_url.strip() or None
if user_settings.llm_base_url
else None
user_base_url = persisted_agent_settings.get('llm.base_url') or getattr(
user_settings, 'llm_base_url', None
)
user_model = user_model.strip() or None if user_model else None
user_base_url = user_base_url.strip() or None if user_base_url else None
# Custom base_url = definitely custom settings (BYOK)
if user_base_url and user_base_url != LITE_LLM_API_URL:
return True

View File

@@ -1,3 +1,4 @@
import os
import uuid
from datetime import datetime
from uuid import UUID
@@ -36,6 +37,20 @@ from storage.stored_conversation_metadata_saas import (
from storage.stored_offline_token import StoredOfflineToken
from storage.stripe_customer import StripeCustomer
from storage.user import User
from storage.user_settings import UserSettings # noqa: F401
@pytest.fixture(autouse=True)
def allow_short_context_windows():
old = os.environ.get('ALLOW_SHORT_CONTEXT_WINDOWS')
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = 'true'
try:
yield
finally:
if old is None:
os.environ.pop('ALLOW_SHORT_CONTEXT_WINDOWS', None)
else:
os.environ['ALLOW_SHORT_CONTEXT_WINDOWS'] = old
@pytest.fixture
@@ -171,7 +186,6 @@ def add_minimal_fixtures(session_maker):
id=uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081'),
name='mock-org',
org_version=ORG_SETTINGS_VERSION,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
)

View File

@@ -106,8 +106,10 @@ async def test_create_org_success(mock_app):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
},
enable_proactive_conversation_starters=True,
)
@@ -140,7 +142,9 @@ async def test_create_org_success(mock_app):
assert response_data['contact_email'] == 'john@example.com'
assert response_data['credits'] == 100.0
assert response_data['org_version'] == 5
assert response_data['default_llm_model'] == 'claude-opus-4-5-20251101'
assert (
response_data['agent_settings']['llm.model'] == 'claude-opus-4-5-20251101'
)
@pytest.mark.asyncio
@@ -427,8 +431,11 @@ async def test_create_org_sensitive_fields_not_exposed(mock_app):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
enable_proactive_conversation_starters=True,
)
@@ -507,7 +514,10 @@ async def test_list_user_orgs_success(mock_app_list):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
},
)
mock_user = MagicMock()
mock_user.current_org_id = org_id
@@ -918,20 +928,23 @@ async def test_list_user_orgs_all_fields_present(mock_app_list):
contact_name='John Doe',
contact_email='john@example.com',
conversation_expiration=3600,
agent='CodeActAgent',
default_max_iterations=50,
security_analyzer='enabled',
confirmation_mode=True,
default_llm_model='claude-opus-4-5-20251101',
default_llm_base_url='https://api.example.com',
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'max_iterations': 50,
'verification.security_analyzer': 'enabled',
'verification.confirmation_mode': True,
'llm.model': 'claude-opus-4-5-20251101',
'llm.base_url': 'https://api.example.com',
'condenser.enabled': True,
'mcp_config': {'key': 'value'},
},
remote_runtime_resource_factor=2,
enable_default_condenser=True,
billing_margin=0.15,
enable_proactive_conversation_starters=True,
sandbox_base_container_image='test-image',
sandbox_runtime_container_image='test-runtime',
org_version=5,
mcp_config={'key': 'value'},
max_budget_per_task=1000.0,
enable_solvability_analysis=True,
v1_enabled=True,
@@ -962,20 +975,18 @@ async def test_list_user_orgs_all_fields_present(mock_app_list):
assert org_data['contact_name'] == 'John Doe'
assert org_data['contact_email'] == 'john@example.com'
assert org_data['conversation_expiration'] == 3600
assert org_data['agent'] == 'CodeActAgent'
assert org_data['default_max_iterations'] == 50
assert org_data['security_analyzer'] == 'enabled'
assert org_data['confirmation_mode'] is True
assert org_data['default_llm_model'] == 'claude-opus-4-5-20251101'
assert org_data['default_llm_base_url'] == 'https://api.example.com'
assert org_data['agent_settings']['agent'] == 'CodeActAgent'
assert org_data['agent_settings']['max_iterations'] == 50
assert org_data['agent_settings']['verification.security_analyzer'] == 'enabled'
assert org_data['agent_settings']['verification.confirmation_mode'] is True
assert org_data['agent_settings']['llm.model'] == 'claude-opus-4-5-20251101'
assert org_data['agent_settings']['llm.base_url'] == 'https://api.example.com'
assert org_data['remote_runtime_resource_factor'] == 2
assert org_data['enable_default_condenser'] is True
assert org_data['billing_margin'] == 0.15
assert org_data['enable_proactive_conversation_starters'] is True
assert org_data['sandbox_base_container_image'] == 'test-image'
assert org_data['sandbox_runtime_container_image'] == 'test-runtime'
assert org_data['org_version'] == 5
assert org_data['mcp_config'] == {'key': 'value'}
assert org_data['max_budget_per_task'] == 1000.0
assert org_data['enable_solvability_analysis'] is True
assert org_data['v1_enabled'] is True
@@ -1020,8 +1031,11 @@ async def test_get_org_success(mock_app_with_get_user_id, mock_owner_role):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
enable_proactive_conversation_starters=True,
)
@@ -1298,8 +1312,11 @@ async def test_get_org_with_credits_none(mock_app_with_get_user_id, mock_owner_r
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
enable_default_condenser=True,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
enable_proactive_conversation_starters=True,
)
@@ -1345,10 +1362,13 @@ async def test_get_org_sensitive_fields_not_exposed(
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
'condenser.enabled': True,
},
search_api_key='secret-search-key-123', # Should not be exposed
sandbox_api_key='secret-sandbox-key-123', # Should not be exposed
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
@@ -1872,7 +1892,7 @@ async def test_update_org_permission_denied_llm_settings(
"""
# Arrange
org_id = uuid.uuid4()
update_data = {'default_llm_model': 'claude-opus-4-5-20251101'}
update_data = {'agent_settings': {'llm.model': 'claude-opus-4-5-20251101'}}
with (
patch(
@@ -2032,13 +2052,13 @@ async def test_update_org_invalid_uuid_format(mock_update_app):
@pytest.mark.asyncio
async def test_update_org_invalid_field_values(mock_update_app, mock_owner_role):
"""
GIVEN: Update request with invalid field values (e.g., negative max_iterations)
GIVEN: Update request with invalid field values (e.g., negative billing margin)
WHEN: PATCH /api/organizations/{org_id} is called
THEN: 422 validation error is returned
"""
# Arrange
org_id = uuid.uuid4()
update_data = {'default_max_iterations': -1} # Invalid: must be > 0
update_data = {'billing_margin': -1} # Invalid: must be >= 0
with patch(
'server.auth.authorization.get_user_org_role',
@@ -2995,20 +3015,24 @@ class TestGetMeEndpoint:
llm_model='gpt-4',
llm_base_url='https://api.example.com',
max_iterations=50,
llm_api_key_for_byor=None,
status_val='active',
):
"""Create a MeResponse for testing."""
agent_settings = {'schema_version': 1}
if llm_model is not None:
agent_settings['llm.model'] = llm_model
if llm_base_url is not None:
agent_settings['llm.base_url'] = llm_base_url
if max_iterations is not None:
agent_settings['max_iterations'] = max_iterations
return MeResponse(
org_id=str(org_id),
user_id=str(user_id),
email=email,
role=role,
llm_api_key=llm_api_key,
llm_model=llm_model,
llm_base_url=llm_base_url,
max_iterations=max_iterations,
llm_api_key_for_byor=llm_api_key_for_byor,
agent_settings=agent_settings,
status=status_val,
)
@@ -3043,9 +3067,9 @@ class TestGetMeEndpoint:
assert data['user_id'] == test_user_id
assert data['email'] == 'owner@example.com'
assert data['role'] == 'owner'
assert data['llm_model'] == 'gpt-4'
assert data['llm_base_url'] == 'https://api.example.com'
assert data['max_iterations'] == 50
assert data['agent_settings']['llm.model'] == 'gpt-4'
assert data['agent_settings']['llm.base_url'] == 'https://api.example.com'
assert data['agent_settings']['max_iterations'] == 50
assert data['status'] == 'active'
@pytest.mark.asyncio
@@ -3169,9 +3193,7 @@ class TestGetMeEndpoint:
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data['llm_model'] is None
assert data['llm_base_url'] is None
assert data['max_iterations'] is None
assert data['agent_settings'] == {'schema_version': 1}
@pytest.mark.asyncio
async def test_get_me_with_admin_role(self, mock_me_app, test_user_id, test_org_id):
@@ -3200,35 +3222,6 @@ class TestGetMeEndpoint:
data = response.json()
assert data['role'] == 'admin'
@pytest.mark.asyncio
async def test_get_me_masks_byor_api_key(
self, mock_me_app, test_user_id, test_org_id
):
"""GIVEN: User has an llm_api_key_for_byor set
WHEN: GET /api/organizations/{org_id}/me is called
THEN: The llm_api_key_for_byor field is also masked
"""
me_response = self._make_me_response(
org_id=test_org_id,
user_id=test_user_id,
llm_api_key_for_byor='****-key', # Masked key
)
with patch(
'server.routes.orgs.OrgMemberService.get_me',
new_callable=AsyncMock,
return_value=me_response,
):
client = TestClient(mock_me_app)
response = client.get(f'/api/organizations/{test_org_id}/me')
assert response.status_code == status.HTTP_200_OK
data = response.json()
assert data['llm_api_key_for_byor'] != 'sk-byor-secret-key'
assert (
data['llm_api_key_for_byor'] is None or '**' in data['llm_api_key_for_byor']
)
@pytest.mark.asyncio
async def test_get_me_role_not_found_returns_500(self, mock_me_app, test_org_id):
"""GIVEN: Role lookup fails (data integrity issue)
@@ -3315,7 +3308,10 @@ async def test_switch_org_success(mock_app_with_get_user_id):
contact_name='John Doe',
contact_email='john@example.com',
org_version=5,
default_llm_model='claude-opus-4-5-20251101',
agent_settings={
'schema_version': 1,
'llm.model': 'claude-opus-4-5-20251101',
},
)
with (

View File

@@ -34,15 +34,15 @@ def mock_org(org_id):
"""Create a mock organization with LLM settings."""
org = MagicMock(spec=Org)
org.id = org_id
org.default_llm_model = 'claude-3'
org.default_llm_base_url = 'https://api.anthropic.com'
org.agent_settings = {
'schema_version': 1,
'llm.model': 'claude-3',
'llm.base_url': 'https://api.anthropic.com',
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
'max_iterations': 50,
}
org.search_api_key = None
org.agent = 'CodeActAgent'
org.confirmation_mode = True
org.security_analyzer = None
org.enable_default_condenser = True
org.condenser_max_size = None
org.default_max_iterations = 50
return org
@@ -78,8 +78,8 @@ async def test_get_org_llm_settings_success(
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'claude-3'
assert result.agent == 'CodeActAgent'
assert result.agent_settings['llm.model'] == 'claude-3'
assert result.agent_settings['agent'] == 'CodeActAgent'
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
@@ -134,20 +134,21 @@ async def test_update_org_llm_settings_success(
# Arrange
updated_org = MagicMock(spec=Org)
updated_org.id = mock_org.id
updated_org.default_llm_model = 'new-model'
updated_org.default_llm_base_url = None
updated_org.agent_settings = {
'schema_version': 1,
'llm.model': 'new-model',
'agent': 'CodeActAgent',
'verification.confirmation_mode': False,
'max_iterations': 100,
}
updated_org.search_api_key = None
updated_org.agent = 'CodeActAgent'
updated_org.confirmation_mode = False
updated_org.security_analyzer = None
updated_org.enable_default_condenser = True
updated_org.condenser_max_size = None
updated_org.default_max_iterations = 100
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
confirmation_mode=False,
default_max_iterations=100,
agent_settings={
'llm.model': 'new-model',
'verification.confirmation_mode': False,
'max_iterations': 100,
}
)
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
@@ -159,9 +160,9 @@ async def test_update_org_llm_settings_success(
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'new-model'
assert result.confirmation_mode is False
assert result.default_max_iterations == 100
assert result.agent_settings['llm.model'] == 'new-model'
assert result.agent_settings['verification.confirmation_mode'] is False
assert result.agent_settings['max_iterations'] == 100
mock_store.update_org_llm_settings.assert_called_once_with(
org_id=mock_org.id,
update_data=update_data,
@@ -189,7 +190,7 @@ async def test_update_org_llm_settings_no_changes(
# Assert
assert isinstance(result, OrgLLMSettingsResponse)
assert result.default_llm_model == 'claude-3'
assert result.agent_settings['llm.model'] == 'claude-3'
mock_store.update_org_llm_settings.assert_not_called()
@@ -203,7 +204,7 @@ async def test_update_org_llm_settings_org_not_found(
THEN: OrgNotFoundError is raised
"""
# Arrange
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
update_data = OrgLLMSettingsUpdate(agent_settings={'llm.model': 'new-model'})
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)

View File

@@ -2224,10 +2224,12 @@ class TestOrgMemberServiceGetMe:
member.user_id = current_user_id
member.role_id = 1
member.llm_api_key = SecretStr('sk-test-key-12345')
member.llm_api_key_for_byor = None
member.llm_model = 'gpt-4'
member.llm_base_url = 'https://api.example.com'
member.max_iterations = 50
member.agent_settings = {
'schema_version': 1,
'llm.model': 'gpt-4',
'llm.base_url': 'https://api.example.com',
'max_iterations': 50,
}
member.status = 'active'
return member
@@ -2275,8 +2277,8 @@ class TestOrgMemberServiceGetMe:
assert result.user_id == str(current_user_id)
assert result.email == 'test@example.com'
assert result.role == 'owner'
assert result.llm_model == 'gpt-4'
assert result.max_iterations == 50
assert result.agent_settings['llm.model'] == 'gpt-4'
assert result.agent_settings['max_iterations'] == 50
assert result.status == 'active'
@pytest.mark.asyncio

View File

@@ -47,7 +47,10 @@ async def test_get_current_org_by_user_id_success(async_session_maker):
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='claude-3')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'llm.model': 'claude-3'},
)
session.add(org)
await session.flush()
@@ -63,7 +66,7 @@ async def test_get_current_org_by_user_id_success(async_session_maker):
# Assert
assert result is not None
assert result.name == 'test-org'
assert result.default_llm_model == 'claude-3'
assert result.agent_settings['llm.model'] == 'claude-3'
@pytest.mark.asyncio
@@ -94,15 +97,20 @@ async def test_update_org_llm_settings_success(async_session_maker):
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='old-model')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'llm.model': 'old-model'},
)
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
agent='CodeActAgent',
confirmation_mode=True,
agent_settings={
'llm.model': 'new-model',
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
}
)
# Act
@@ -115,9 +123,9 @@ async def test_update_org_llm_settings_success(async_session_maker):
# Assert
assert result is not None
assert result.default_llm_model == 'new-model'
assert result.agent == 'CodeActAgent'
assert result.confirmation_mode is True
assert result.agent_settings['llm.model'] == 'new-model'
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['verification.confirmation_mode'] is True
@pytest.mark.asyncio
@@ -129,7 +137,7 @@ async def test_update_org_llm_settings_org_not_found(async_session_maker):
"""
# Arrange
non_existent_org_id = uuid.uuid4()
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
update_data = OrgLLMSettingsUpdate(agent_settings={'llm.model': 'new-model'})
# Act
async with async_session_maker() as session:
@@ -149,13 +157,16 @@ async def test_update_org_llm_settings_propagates_to_members(async_session_maker
"""
# Arrange
async with async_session_maker() as session:
org = Org(name='test-org', default_llm_model='old-model')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'llm.model': 'old-model'},
)
session.add(org)
await session.commit()
org_id = org.id
update_data = OrgLLMSettingsUpdate(
default_llm_model='new-model',
agent_settings={'llm.model': 'new-model'},
llm_api_key='new-api-key',
)
@@ -171,5 +182,5 @@ async def test_update_org_llm_settings_propagates_to_members(async_session_maker
mock_update_members.assert_called_once()
call_args = mock_update_members.call_args
member_settings = call_args[0][2]
assert member_settings.llm_model == 'new-model'
assert member_settings.agent_settings is None
assert member_settings.llm_api_key == 'new-api-key'

View File

@@ -76,13 +76,11 @@ async def async_session_with_users(async_engine) -> AsyncGenerator[AsyncSession,
org1 = Org(
id=ORG1_ID,
name='test-org-1',
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
org2 = Org(
id=ORG2_ID,
name='test-org-2',
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
db_session.add(org1)

View File

@@ -75,7 +75,6 @@ async def test_org(async_session_maker):
id=org_id,
name=f'test-org-{org_id}',
org_version=ORG_SETTINGS_VERSION,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
session.add(org)

View File

@@ -23,6 +23,15 @@ from storage.user_settings import UserSettings
from openhands.server.settings import Settings
def _agent_value(settings: Settings, key: str):
return settings.get_agent_setting(key)
def _secret_value(settings: Settings, key: str):
secret = settings.get_secret_agent_setting(key)
return secret.get_secret_value() if secret else None
class TestDefaultInitialBudget:
"""Test cases for DEFAULT_INITIAL_BUDGET configuration."""
@@ -122,20 +131,22 @@ class TestLiteLlmManager:
def mock_settings(self):
"""Create a mock Settings object."""
settings = Settings()
settings.agent = 'TestAgent'
settings.llm_model = 'test-model'
settings.llm_api_key = SecretStr('test-key')
settings.llm_base_url = 'http://test.com'
settings.set_agent_setting('agent', 'TestAgent')
settings.set_agent_setting('llm.model', 'test-model')
settings.set_agent_setting('llm.api_key', SecretStr('test-key'))
settings.set_agent_setting('llm.base_url', 'http://test.com')
return settings
@pytest.fixture
def mock_user_settings(self):
"""Create a mock UserSettings object."""
user_settings = UserSettings()
user_settings.agent = 'TestAgent'
user_settings.llm_model = 'test-model'
user_settings.agent_settings = {
'agent': 'TestAgent',
'llm.model': 'test-model',
'llm.base_url': 'http://test.com',
}
user_settings.llm_api_key = SecretStr('test-key')
user_settings.llm_base_url = 'http://test.com'
user_settings.user_version = 4 # Set version to avoid None comparison
return user_settings
@@ -228,10 +239,14 @@ class TestLiteLlmManager:
)
assert result is not None
assert result.agent == 'CodeActAgent'
assert result.llm_model == get_default_litellm_model()
assert result.llm_api_key.get_secret_value() == 'test-key'
assert result.llm_base_url == 'http://test.com'
assert _agent_value(result, 'agent') == 'CodeActAgent'
assert _agent_value(
result, 'llm.model'
) == get_default_litellm_model().replace(
'litellm_proxy/', 'openhands/'
)
assert _secret_value(result, 'llm.api_key') == 'test-key'
assert _agent_value(result, 'llm.base_url') == 'http://test.com'
@pytest.mark.asyncio
async def test_create_entries_cloud_deployment(self, mock_settings, mock_response):
@@ -275,10 +290,12 @@ class TestLiteLlmManager:
)
assert result is not None
assert result.agent == 'CodeActAgent'
assert result.llm_model == get_default_litellm_model()
assert result.llm_api_key.get_secret_value() == 'test-api-key'
assert result.llm_base_url == 'http://test.com'
assert _agent_value(result, 'agent') == 'CodeActAgent'
assert _agent_value(
result, 'llm.model'
) == get_default_litellm_model().replace('litellm_proxy/', 'openhands/')
assert _secret_value(result, 'llm.api_key') == 'test-api-key'
assert _agent_value(result, 'llm.base_url') == 'http://test.com'
# Verify API calls were made (get_team + user_exists + 4 posts)
assert mock_client.get.call_count == 2 # get_team + user_exists
@@ -530,10 +547,14 @@ class TestLiteLlmManager:
# migrate_entries returns the user_settings unchanged
assert result is not None
assert result.agent == 'TestAgent'
assert result.llm_model == 'test-model'
effective_settings = result.to_settings()
assert _agent_value(effective_settings, 'agent') == 'TestAgent'
assert _agent_value(effective_settings, 'llm.model') == 'test-model'
assert result.llm_api_key.get_secret_value() == 'test-key'
assert result.llm_base_url == 'http://test.com'
assert (
_agent_value(effective_settings, 'llm.base_url')
== 'http://test.com'
)
@pytest.mark.asyncio
async def test_migrate_entries_no_user_found(self, mock_user_settings):
@@ -654,10 +675,19 @@ class TestLiteLlmManager:
# migrate_entries returns the user_settings unchanged
assert result is not None
assert result.agent == 'TestAgent'
assert result.llm_model == 'test-model'
effective_settings = result.to_settings()
assert (
_agent_value(effective_settings, 'agent') == 'TestAgent'
)
assert (
_agent_value(effective_settings, 'llm.model')
== 'test-model'
)
assert result.llm_api_key.get_secret_value() == 'test-key'
assert result.llm_base_url == 'http://test.com'
assert (
_agent_value(effective_settings, 'llm.base_url')
== 'http://test.com'
)
# Verify migration steps were called:
# - 2 GET requests: _get_user, _get_user_keys
@@ -739,8 +769,9 @@ class TestLiteLlmManager:
result.llm_api_key.get_secret_value()
== 'new-generated-key'
)
assert result.llm_api_key_for_byor_secret is not None
assert (
result.llm_api_key_for_byor.get_secret_value()
result.llm_api_key_for_byor_secret.get_secret_value()
== 'new-generated-key'
)
@@ -2031,7 +2062,10 @@ class TestLiteLlmManager:
# downgrade_entries returns the user_settings
assert result is not None
assert result.agent == 'TestAgent'
assert (
_agent_value(result.to_settings(), 'agent')
== 'TestAgent'
)
# Verify downgrade steps were called:
# GET requests:
@@ -2065,7 +2099,7 @@ class TestLiteLlmManager:
# In local deployment, should return user_settings without
# making any LiteLLM calls
assert result is not None
assert result.agent == 'TestAgent'
assert _agent_value(result.to_settings(), 'agent') == 'TestAgent'
class TestGetAllKeysForUser:

View File

@@ -10,6 +10,7 @@ from server.routes.org_invitation_models import (
)
from server.services.org_invitation_service import OrgInvitationService
from storage.org_invitation import OrgInvitation
from storage.org_store import OrgStore
class TestAcceptInvitationEmailValidation:
@@ -99,9 +100,7 @@ class TestAcceptInvitationEmailValidation:
mock_keycloak_user_info = {'email': 'alice@example.com'} # Email from Keycloak
mock_org = MagicMock()
mock_org.default_llm_model = 'test-model'
mock_org.default_llm_base_url = None
mock_org.default_max_iterations = None
mock_org.agent_settings = {'schema_version': 1, 'llm.model': 'test-model'}
with (
patch(
@@ -225,9 +224,7 @@ class TestAcceptInvitationEmailValidation:
mock_invitation.email = 'alice@example.com' # Lowercase in invitation
mock_org = MagicMock()
mock_org.default_llm_model = 'test-model'
mock_org.default_llm_base_url = None
mock_org.default_max_iterations = None
mock_org.agent_settings = {'schema_version': 1, 'llm.model': 'test-model'}
with (
patch(
@@ -290,9 +287,12 @@ class TestAcceptInvitationEmailValidation:
mock_user.email = 'alice@example.com'
mock_org = MagicMock()
mock_org.default_llm_model = 'claude-sonnet-4'
mock_org.default_llm_base_url = 'https://api.anthropic.com'
mock_org.default_max_iterations = 100
mock_org.agent_settings = {
'schema_version': 1,
'llm.model': 'claude-sonnet-4',
'llm.base_url': 'https://api.anthropic.com',
'max_iterations': 100,
}
with (
patch(
@@ -332,7 +332,7 @@ class TestAcceptInvitationEmailValidation:
mock_get_user.return_value = mock_user
mock_get_member.return_value = None
mock_settings = MagicMock()
mock_settings.llm_api_key = SecretStr('test-key')
mock_settings.get_secret_agent_setting.return_value = SecretStr('test-key')
mock_create_litellm.return_value = mock_settings
mock_get_org.return_value = mock_org
mock_update_status.return_value = mock_invitation
@@ -340,12 +340,14 @@ class TestAcceptInvitationEmailValidation:
# Act
await OrgInvitationService.accept_invitation(token, user_id)
# Assert - verify add_user_to_org was called with org's LLM settings
# Assert - verify add_user_to_org snapshots the org defaults onto
# the new membership row's canonical agent_settings blob.
mock_add_user.assert_called_once()
call_kwargs = mock_add_user.call_args.kwargs
assert call_kwargs['llm_model'] == 'claude-sonnet-4'
assert call_kwargs['llm_base_url'] == 'https://api.anthropic.com'
assert call_kwargs['max_iterations'] == 100
assert call_kwargs['llm_api_key'] == 'test-key'
assert call_kwargs[
'agent_settings'
] == OrgStore.get_agent_settings_from_org(mock_org)
class TestCreateInvitationsBatch:

View File

@@ -10,6 +10,69 @@ from storage.org_member import OrgMember
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.user import User
from storage.user_settings import UserSettings
def test_get_kwargs_from_user_settings_uses_agent_settings_as_source_of_truth():
user_settings = UserSettings(
llm_api_key='legacy-secret',
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
'condenser.enabled': False,
'condenser.max_size': 128,
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
'llm.base_url': 'https://api.example.com',
'max_iterations': 42,
},
)
kwargs = OrgMemberStore.get_kwargs_from_user_settings(user_settings)
assert kwargs['llm_api_key'] == 'legacy-secret'
assert (
kwargs['agent_settings']
| {
'schema_version': 1,
'agent': 'CodeActAgent',
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
'condenser.enabled': False,
'condenser.max_size': 128,
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
'llm.base_url': 'https://api.example.com',
'max_iterations': 42,
}
== kwargs['agent_settings']
)
def test_get_agent_settings_from_org_member_uses_canonical_snapshot_json():
org_member = OrgMember(
org_id=uuid.uuid4(),
user_id=uuid.uuid4(),
role_id=1,
llm_api_key='legacy-secret',
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'member-model',
'llm.base_url': 'https://member.example.com',
'max_iterations': 42,
'verification.confirmation_mode': True,
},
)
assert OrgMemberStore.get_agent_settings_from_org_member(org_member) == {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'member-model',
'llm.base_url': 'https://member.example.com',
'max_iterations': 42,
'verification.confirmation_mode': True,
}
@pytest.fixture
@@ -271,16 +334,19 @@ async def test_add_user_to_org_with_llm_settings(async_session_maker):
role_id=role_id,
llm_api_key='test-api-key',
status='active',
llm_model='claude-sonnet-4',
llm_base_url='https://api.example.com',
max_iterations=50,
agent_settings={
'schema_version': 1,
'llm.model': 'claude-sonnet-4',
'llm.base_url': 'https://api.example.com',
'max_iterations': 50,
},
)
# Assert
assert org_member is not None
assert org_member.llm_model == 'claude-sonnet-4'
assert org_member.llm_base_url == 'https://api.example.com'
assert org_member.max_iterations == 50
assert org_member.agent_settings['llm.model'] == 'claude-sonnet-4'
assert org_member.agent_settings['llm.base_url'] == 'https://api.example.com'
assert org_member.agent_settings['max_iterations'] == 50
@pytest.mark.asyncio
@@ -977,8 +1043,11 @@ async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
user_id=user.id,
role_id=role.id,
llm_api_key='test-key',
llm_model='old-model',
max_iterations=10,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model',
'max_iterations': 10,
},
status='active',
)
session.add(org_member)
@@ -987,9 +1056,11 @@ async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
# Act
member_settings = OrgMemberLLMSettings(
llm_model='new-model',
llm_base_url='https://new-url.com',
max_iterations=50,
agent_settings={
'llm.model': 'new-model',
'llm.base_url': 'https://new-url.com',
'max_iterations': 50,
}
)
async with async_session_maker() as session:
@@ -1007,9 +1078,9 @@ async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
)
updated_member = result.scalars().first()
assert updated_member.llm_model == 'new-model'
assert updated_member.llm_base_url == 'https://new-url.com'
assert updated_member.max_iterations == 50
assert updated_member.agent_settings['llm.model'] == 'new-model'
assert updated_member.agent_settings['llm.base_url'] == 'https://new-url.com'
assert updated_member.agent_settings['max_iterations'] == 50
@pytest.mark.asyncio
@@ -1042,7 +1113,10 @@ async def test_update_all_members_llm_settings_async_with_empty_settings(
user_id=user.id,
role_id=role.id,
llm_api_key='original-key',
llm_model='original-model',
agent_settings={
'schema_version': 1,
'llm.model': 'original-model',
},
status='active',
)
session.add(org_member)
@@ -1067,7 +1141,7 @@ async def test_update_all_members_llm_settings_async_with_empty_settings(
)
member = result.scalars().first()
assert member.llm_model == 'original-model'
assert member.agent_settings['llm.model'] == 'original-model'
# Original key should still be there (encrypted)
assert member._llm_api_key is not None
@@ -1115,9 +1189,9 @@ def test_org_member_llm_settings_has_updates_empty():
def test_org_llm_settings_update_apply_to_org_skips_llm_api_key():
"""
GIVEN: OrgLLMSettingsUpdate with llm_api_key and other fields set
GIVEN: OrgLLMSettingsUpdate with search_api_key and llm_api_key set
WHEN: apply_to_org() is called
THEN: llm_api_key is NOT applied to org, but other fields are
THEN: search_api_key is applied to org, but llm_api_key is not
"""
from unittest.mock import MagicMock
@@ -1125,18 +1199,17 @@ def test_org_llm_settings_update_apply_to_org_skips_llm_api_key():
# Arrange
settings = OrgLLMSettingsUpdate(
default_llm_model='claude-3',
search_api_key='applied-to-org',
llm_api_key='should-not-be-applied',
)
mock_org = MagicMock()
mock_org.default_llm_model = None
mock_org.search_api_key = None
# Act
settings.apply_to_org(mock_org)
# Assert
assert mock_org.default_llm_model == 'claude-3'
# llm_api_key should NOT be set on org (it's member-only)
assert mock_org.search_api_key == 'applied-to-org'
assert (
not hasattr(mock_org, 'llm_api_key')
or mock_org.llm_api_key != 'should-not-be-applied'
@@ -1153,7 +1226,7 @@ def test_org_llm_settings_update_get_member_updates_includes_llm_api_key():
# Arrange
settings = OrgLLMSettingsUpdate(
default_llm_model='claude-3',
agent_settings={'llm.model': 'claude-3'},
llm_api_key='new-member-key',
)
@@ -1163,7 +1236,7 @@ def test_org_llm_settings_update_get_member_updates_includes_llm_api_key():
# Assert
assert member_updates is not None
assert member_updates.llm_api_key == 'new-member-key'
assert member_updates.llm_model == 'claude-3'
assert member_updates.agent_settings is None
def test_org_llm_settings_update_get_member_updates_only_llm_api_key():
@@ -1183,7 +1256,7 @@ def test_org_llm_settings_update_get_member_updates_only_llm_api_key():
# Assert
assert member_updates is not None
assert member_updates.llm_api_key == 'member-key-only'
assert member_updates.llm_model is None
assert member_updates.agent_settings is None
def test_org_llm_settings_update_has_updates_with_llm_api_key():

View File

@@ -139,7 +139,12 @@ async def test_create_org_with_owner_success(
),
patch(
'storage.org_service.OrgStore.get_kwargs_from_settings',
return_value={},
return_value={
'agent_settings': {
'schema_version': 1,
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
}
},
),
patch(
'storage.org_service.OrgMemberStore.get_kwargs_from_settings',
@@ -160,7 +165,9 @@ async def test_create_org_with_owner_success(
assert result.contact_name == contact_name
assert result.contact_email == contact_email
assert result.org_version > 0 # Should be set to ORG_SETTINGS_VERSION
assert result.default_llm_model is not None # Should be set
assert (
result.agent_settings['llm.model'] == 'anthropic/claude-sonnet-4-5-20250929'
)
# Verify organization was persisted
with session_maker() as session:
@@ -1192,8 +1199,10 @@ async def test_update_org_with_permissions_success_llm_fields_admin(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(
default_llm_model='claude-opus-4-5-20251101',
default_llm_base_url='https://api.anthropic.com',
agent_settings={
'llm.model': 'claude-opus-4-5-20251101',
'llm.base_url': 'https://api.anthropic.com',
}
)
with (
@@ -1210,8 +1219,8 @@ async def test_update_org_with_permissions_success_llm_fields_admin(
# Assert
assert result is not None
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.default_llm_base_url == 'https://api.anthropic.com'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.agent_settings['llm.base_url'] == 'https://api.anthropic.com'
@pytest.mark.asyncio
@@ -1254,8 +1263,10 @@ async def test_update_org_with_permissions_success_llm_fields_owner(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(
default_llm_model='claude-opus-4-5-20251101',
security_analyzer='enabled',
agent_settings={
'llm.model': 'claude-opus-4-5-20251101',
'verification.security_analyzer': 'enabled',
}
)
with (
@@ -1272,8 +1283,8 @@ async def test_update_org_with_permissions_success_llm_fields_owner(
# Assert
assert result is not None
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.security_analyzer == 'enabled'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.agent_settings['verification.security_analyzer'] == 'enabled'
@pytest.mark.asyncio
@@ -1317,7 +1328,7 @@ async def test_update_org_with_permissions_success_mixed_fields_admin(
update_data = OrgUpdate(
contact_name='Jane Doe',
default_llm_model='claude-opus-4-5-20251101',
agent_settings={'llm.model': 'claude-opus-4-5-20251101'},
conversation_expiration=30,
)
@@ -1336,7 +1347,7 @@ async def test_update_org_with_permissions_success_mixed_fields_admin(
# Assert
assert result is not None
assert result.contact_name == 'Jane Doe'
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.conversation_expiration == 30
@@ -1520,7 +1531,7 @@ async def test_update_org_with_permissions_llm_fields_insufficient_permission(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(default_llm_model='claude-opus-4-5-20251101')
update_data = OrgUpdate(agent_settings={'llm.model': 'claude-opus-4-5-20251101'})
with (
patch('storage.org_store.a_session_maker', async_session_maker),
@@ -1763,9 +1774,11 @@ async def test_update_org_with_permissions_only_llm_fields(
from server.routes.org_models import OrgUpdate
update_data = OrgUpdate(
default_llm_model='claude-opus-4-5-20251101',
security_analyzer='enabled',
agent='agent-mode',
agent_settings={
'llm.model': 'claude-opus-4-5-20251101',
'verification.security_analyzer': 'enabled',
'agent': 'agent-mode',
}
)
with (
@@ -1782,9 +1795,9 @@ async def test_update_org_with_permissions_only_llm_fields(
# Assert
assert result is not None
assert result.default_llm_model == 'claude-opus-4-5-20251101'
assert result.security_analyzer == 'enabled'
assert result.agent == 'agent-mode'
assert result.agent_settings['llm.model'] == 'claude-opus-4-5-20251101'
assert result.agent_settings['verification.security_analyzer'] == 'enabled'
assert result.agent_settings['agent'] == 'agent-mode'
@pytest.mark.asyncio

View File

@@ -97,7 +97,10 @@ async def test_update_org(async_session_maker, mock_litellm_api):
# Test updating org details
async with async_session_maker() as session:
# Create a test org
org = Org(name='test-org', agent='CodeActAgent')
org = Org(
name='test-org',
agent_settings={'schema_version': 1, 'agent': 'CodeActAgent'},
)
session.add(org)
await session.commit()
await session.refresh(org)
@@ -108,12 +111,16 @@ async def test_update_org(async_session_maker, mock_litellm_api):
patch('storage.org_store.a_session_maker', async_session_maker),
):
updated_org = await OrgStore.update_org(
org_id=org_id, kwargs={'name': 'updated-org', 'agent': 'PlannerAgent'}
org_id=org_id,
kwargs={
'name': 'updated-org',
'agent_settings': {'agent': 'PlannerAgent'},
},
)
assert updated_org is not None
assert updated_org.name == 'updated-org'
assert updated_org.agent == 'PlannerAgent'
assert updated_org.agent_settings['agent'] == 'PlannerAgent'
@pytest.mark.asyncio
@@ -135,12 +142,15 @@ async def test_create_org(async_session_maker, mock_litellm_api):
patch('storage.org_store.a_session_maker', async_session_maker),
):
org = await OrgStore.create_org(
kwargs={'name': 'new-org', 'agent': 'CodeActAgent'}
kwargs={
'name': 'new-org',
'agent_settings': {'schema_version': 1, 'agent': 'CodeActAgent'},
}
)
assert org is not None
assert org.name == 'new-org'
assert org.agent == 'CodeActAgent'
assert org.agent_settings['agent'] == 'CodeActAgent'
assert org.id is not None
@@ -277,7 +287,7 @@ def test_get_kwargs_from_settings():
settings = Settings(
language='es',
agent='CodeActAgent',
llm_model='gpt-4',
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test-key'),
enable_sound_notifications=True,
)
@@ -285,10 +295,13 @@ def test_get_kwargs_from_settings():
kwargs = OrgStore.get_kwargs_from_settings(settings)
# Should only include fields that exist in Org model
assert 'agent' in kwargs
assert 'default_llm_model' in kwargs
assert kwargs['agent'] == 'CodeActAgent'
assert kwargs['default_llm_model'] == 'gpt-4'
assert 'agent_settings' in kwargs
assert 'agent' not in kwargs
assert 'default_llm_model' not in kwargs
assert kwargs['agent_settings']['agent'] == 'CodeActAgent'
assert (
kwargs['agent_settings']['llm.model'] == 'anthropic/claude-sonnet-4-5-20250929'
)
# Should not include fields that don't exist in Org model
assert 'language' not in kwargs # language is not in Org model
assert 'llm_api_key' not in kwargs
@@ -379,7 +392,7 @@ async def test_persist_org_with_owner_returns_refreshed_org(
name='Test Org',
contact_name='Jane Doe',
contact_email='jane@example.com',
agent='CodeActAgent',
agent_settings={'schema_version': 1, 'agent': 'CodeActAgent'},
)
org_member = OrgMember(
@@ -397,7 +410,7 @@ async def test_persist_org_with_owner_returns_refreshed_org(
# Assert - verify the returned object has database-generated fields
assert result.id == org_id
assert result.name == 'Test Org'
assert result.agent == 'CodeActAgent'
assert result.agent_settings['agent'] == 'CodeActAgent'
# Verify org_version was set by create_org logic (if applicable)
assert hasattr(result, 'org_version')
@@ -480,9 +493,12 @@ async def test_persist_org_with_owner_with_multiple_fields(
name='Complex Org',
contact_name='Alice Smith',
contact_email='alice@example.com',
agent='CodeActAgent',
default_max_iterations=50,
confirmation_mode=True,
agent_settings={
'schema_version': 1,
'agent': 'CodeActAgent',
'max_iterations': 50,
'verification.confirmation_mode': True,
},
billing_margin=0.15,
)
@@ -492,8 +508,11 @@ async def test_persist_org_with_owner_with_multiple_fields(
role_id=1,
status='active',
llm_api_key='test-key',
max_iterations=100,
llm_model='gpt-4',
agent_settings={
'schema_version': 1,
'max_iterations': 100,
'llm.model': 'gpt-4',
},
)
# Act
@@ -502,25 +521,25 @@ async def test_persist_org_with_owner_with_multiple_fields(
# Assert
assert result.name == 'Complex Org'
assert result.agent == 'CodeActAgent'
assert result.default_max_iterations == 50
assert result.confirmation_mode is True
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['max_iterations'] == 50
assert result.agent_settings['verification.confirmation_mode'] is True
assert result.billing_margin == 0.15
# Verify persistence
async with async_session_maker() as session:
persisted_org = await session.get(Org, org_id)
assert persisted_org.agent == 'CodeActAgent'
assert persisted_org.default_max_iterations == 50
assert persisted_org.confirmation_mode is True
assert persisted_org.agent_settings['agent'] == 'CodeActAgent'
assert persisted_org.agent_settings['max_iterations'] == 50
assert persisted_org.agent_settings['verification.confirmation_mode'] is True
assert persisted_org.billing_margin == 0.15
result_query = await session.execute(
select(OrgMember).filter_by(org_id=org_id, user_id=user_id)
)
persisted_member = result_query.scalars().first()
assert persisted_member.max_iterations == 100
assert persisted_member.llm_model == 'gpt-4'
assert persisted_member.agent_settings['max_iterations'] == 100
assert persisted_member.agent_settings['llm.model'] == 'gpt-4'
@pytest.mark.asyncio
@@ -1030,11 +1049,11 @@ async def test_update_org_llm_settings_async_with_llm_api_key():
mock_org = Org(
id=org_id,
name='Test Organization',
default_llm_model='old-model',
agent_settings={'schema_version': 1, 'llm.model': 'old-model'},
)
llm_settings = OrgLLMSettingsUpdate(
default_llm_model='new-model',
agent_settings={'llm.model': 'new-model'},
llm_api_key='new-member-api-key',
)
@@ -1062,14 +1081,14 @@ async def test_update_org_llm_settings_async_with_llm_api_key():
# Assert - Org is returned
assert result is not None
assert result.default_llm_model == 'new-model'
assert result.agent_settings['llm.model'] == 'new-model'
# Assert - Member update was called with correct settings
mock_member_update.assert_called_once()
call_args = mock_member_update.call_args
member_settings = call_args[0][2] # Third positional arg is member_settings
assert member_settings.llm_api_key == 'new-member-api-key'
assert member_settings.llm_model == 'new-model'
assert member_settings.agent_settings is None
@pytest.mark.asyncio
@@ -1083,7 +1102,7 @@ async def test_update_org_llm_settings_async_org_not_found():
# Arrange
non_existent_org_id = uuid.uuid4()
llm_settings = OrgLLMSettingsUpdate(default_llm_model='new-model')
llm_settings = OrgLLMSettingsUpdate(agent_settings={'llm.model': 'new-model'})
# Mock the async session to return None for org
mock_session = AsyncMock()

View File

@@ -8,11 +8,22 @@ from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings as DataSettings
def _agent_value(settings: Settings, key: str):
return settings.get_agent_setting(key)
def _secret_value(settings: Settings, key: str):
secret = settings.get_secret_agent_setting(key)
return secret.get_secret_value() if secret else None
# Mock the database module before importing
with patch('storage.database.a_session_maker'):
from server.constants import (
LITE_LLM_API_URL,
)
from storage.encrypt_utils import decrypt_legacy_value, encrypt_legacy_value
from storage.saas_settings_store import SaasSettingsStore
from storage.user_settings import UserSettings
@@ -26,6 +37,34 @@ def mock_config():
return config
def test_member_settings_persist_full_effective_agent_settings(mock_config):
settings = Settings(
agent='CodeActAgent',
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_base_url='https://api.example.com',
max_iterations=42,
confirmation_mode=True,
security_analyzer='llm',
enable_default_condenser=False,
condenser_max_size=128,
)
expected = {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
'llm.base_url': 'https://api.example.com',
'max_iterations': 42,
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
'condenser.enabled': False,
'condenser.max_size': 128,
}
actual = settings.normalized_agent_settings(strip_secret_values=True)
assert actual | expected == actual
@pytest.fixture
def settings_store(async_session_maker, mock_config):
store = SaasSettingsStore('5594c7b6-f959-4b81-92e9-b09c206f5081', mock_config)
@@ -45,6 +84,7 @@ def settings_store(async_session_maker, mock_config):
if not user_settings:
# Return default settings
return Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test_api_key'),
llm_base_url='http://test.url',
agent='CodeActAgent',
@@ -54,12 +94,20 @@ def settings_store(async_session_maker, mock_config):
# Decrypt and convert to Settings
kwargs = {}
for column in UserSettings.__table__.columns:
if column.name != 'keycloak_user_id':
value = getattr(user_settings, column.name, None)
if value is not None:
kwargs[column.name] = value
if column.name == 'keycloak_user_id':
continue
value = getattr(user_settings, column.name, None)
if value is None:
continue
if column.name in {
'llm_api_key',
'llm_api_key_for_byor',
'search_api_key',
'sandbox_api_key',
}:
value = decrypt_legacy_value(value)
kwargs[column.name] = value
store._decrypt_kwargs(kwargs)
settings = Settings(**kwargs)
settings.email = 'test@example.com'
settings.email_verified = True
@@ -70,6 +118,7 @@ def settings_store(async_session_maker, mock_config):
if item:
# Make a copy of the item without email and email_verified
item_dict = item.model_dump(context={'expose_secrets': True})
item_dict['llm_api_key'] = _secret_value(item, 'llm.api_key')
if 'email' in item_dict:
del item_dict['email']
if 'email_verified' in item_dict:
@@ -78,7 +127,13 @@ def settings_store(async_session_maker, mock_config):
del item_dict['secrets_store']
# Encrypt the data before storing
store._encrypt_kwargs(item_dict)
for key in ('llm_api_key', 'search_api_key', 'sandbox_api_key'):
value = item_dict.get(key)
if value is not None:
item_dict[key] = encrypt_legacy_value(value)
item_dict['agent_settings'] = item.normalized_agent_settings(
strip_secret_values=True
)
# Continue with the original implementation
from sqlalchemy import select
@@ -114,11 +169,16 @@ async def test_store_and_load_keycloak_user(settings_store):
# Set a UUID-like Keycloak user ID
settings_store.user_id = '550e8400-e29b-41d4-a716-446655440000'
settings = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('secret_key'),
llm_base_url=LITE_LLM_API_URL,
agent='smith',
email='test@example.com',
email_verified=True,
agent_settings={
'verification.critic_mode': 'all_actions',
'verification.critic_enabled': True,
},
)
await settings_store.store(settings)
@@ -126,8 +186,10 @@ async def test_store_and_load_keycloak_user(settings_store):
# Load and verify settings
loaded_settings = await settings_store.load()
assert loaded_settings is not None
assert loaded_settings.llm_api_key.get_secret_value() == 'secret_key'
assert loaded_settings.agent == 'smith'
assert _agent_value(loaded_settings, 'verification.critic_mode') == 'all_actions'
assert _agent_value(loaded_settings, 'verification.critic_enabled') is True
assert _secret_value(loaded_settings, 'llm.api_key') == 'secret_key'
assert _agent_value(loaded_settings, 'agent') == 'smith'
# Verify it was stored in user_settings table with keycloak_user_id
from sqlalchemy import select
@@ -140,7 +202,7 @@ async def test_store_and_load_keycloak_user(settings_store):
)
stored = result.scalars().first()
assert stored is not None
assert stored.agent == 'smith'
assert stored.agent_settings['agent'] == 'smith'
@pytest.mark.asyncio
@@ -154,15 +216,16 @@ async def test_load_returns_default_when_not_found(settings_store, async_session
loaded_settings = await settings_store.load()
assert loaded_settings is not None
assert loaded_settings.language == 'en'
assert loaded_settings.agent == 'CodeActAgent'
assert loaded_settings.llm_api_key.get_secret_value() == 'test_api_key'
assert loaded_settings.llm_base_url == 'http://test.url'
assert _agent_value(loaded_settings, 'agent') == 'CodeActAgent'
assert _secret_value(loaded_settings, 'llm.api_key') == 'test_api_key'
assert _agent_value(loaded_settings, 'llm.base_url') == 'http://test.url'
@pytest.mark.asyncio
async def test_encryption(settings_store):
settings_store.user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081' # GitHub user ID
settings = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('secret_key'),
agent='smith',
llm_base_url=LITE_LLM_API_URL,
@@ -183,7 +246,7 @@ async def test_encryption(settings_store):
assert stored.llm_api_key != 'secret_key'
# But we should be able to decrypt it when loading
loaded_settings = await settings_store.load()
assert loaded_settings.llm_api_key.get_secret_value() == 'secret_key'
assert _secret_value(loaded_settings, 'llm.api_key') == 'secret_key'
@pytest.mark.asyncio
@@ -203,8 +266,8 @@ async def test_ensure_api_key_keeps_valid_key(mock_config):
await store._ensure_api_key(item, 'org-123', openhands_type=True)
# Key should remain unchanged when it's valid
assert item.llm_api_key is not None
assert item.llm_api_key.get_secret_value() == existing_key
assert _secret_value(item, 'llm.api_key') is not None
assert _secret_value(item, 'llm.api_key') == existing_key
@pytest.mark.asyncio
@@ -232,8 +295,8 @@ async def test_ensure_api_key_generates_new_key_when_verification_fails(
):
await store._ensure_api_key(item, 'org-123', openhands_type=True)
assert item.llm_api_key is not None
assert item.llm_api_key.get_secret_value() == new_key
assert _secret_value(item, 'llm.api_key') is not None
assert _secret_value(item, 'llm.api_key') == new_key
@pytest.fixture
@@ -264,7 +327,6 @@ def org_with_multiple_members_fixture(session_maker):
id=org_id,
name='test-org',
org_version=1,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
session.add(org)
@@ -291,9 +353,12 @@ def org_with_multiple_members_fixture(session_maker):
user_id=admin_user_id,
role_id=10,
llm_api_key='admin-initial-key',
llm_model='old-model-v1',
llm_base_url='http://old-url-1.com',
max_iterations=10,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model-v1',
'llm.base_url': 'http://old-url-1.com',
'max_iterations': 10,
},
status='active',
)
session.add(admin_member)
@@ -303,9 +368,12 @@ def org_with_multiple_members_fixture(session_maker):
user_id=member1_user_id,
role_id=10,
llm_api_key='member1-initial-key',
llm_model='old-model-v2',
llm_base_url='http://old-url-2.com',
max_iterations=20,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model-v2',
'llm.base_url': 'http://old-url-2.com',
'max_iterations': 20,
},
status='active',
)
session.add(member1)
@@ -315,9 +383,12 @@ def org_with_multiple_members_fixture(session_maker):
user_id=member2_user_id,
role_id=10,
llm_api_key='member2-initial-key',
llm_model='old-model-v3',
llm_base_url='http://old-url-3.com',
max_iterations=30,
agent_settings={
'schema_version': 1,
'llm.model': 'old-model-v3',
'llm.base_url': 'http://old-url-3.com',
'max_iterations': 30,
},
status='active',
)
session.add(member2)
@@ -334,270 +405,265 @@ def org_with_multiple_members_fixture(session_maker):
@pytest.mark.asyncio
async def test_store_propagates_llm_settings_to_all_org_members(
async def test_store_updates_org_defaults_and_all_members_for_shared_keys(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When admin saves LLM settings, all org members should receive the updated settings.
This test verifies using a real database that:
1. The bulk UPDATE targets the correct organization (WHERE clause is correct)
2. All LLM fields are correctly set (llm_model, llm_base_url, max_iterations, llm_api_key)
3. The llm_api_key is properly encrypted
4. All members in the org receive the same updated values
"""
"""External provider keys should still sync as an org-wide shared snapshot."""
from sqlalchemy import select
from storage.org import Org
from storage.org_member import OrgMember
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
decrypt_value = fixture['decrypt_value']
store = SaasSettingsStore(str(fixture['admin_user_id']), mock_config)
new_settings = DataSettings(
llm_model='anthropic/claude-sonnet-4',
llm_base_url='https://api.anthropic.com/v1',
max_iterations=100,
llm_api_key=SecretStr('shared-external-api-key'),
)
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
with session_maker() as session:
org = session.execute(select(Org).where(Org.id == org_id)).scalars().first()
assert org is not None
assert org.agent_settings['llm.model'] == 'anthropic/claude-sonnet-4'
assert org.agent_settings['llm.base_url'] == 'https://api.anthropic.com/v1'
assert org.agent_settings['max_iterations'] == 100
members = {
str(member.user_id): member
for member in session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
).scalars().all()
}
assert len(members) == 3
for member in members.values():
assert member.agent_settings['llm.model'] == 'anthropic/claude-sonnet-4'
assert member.agent_settings['llm.base_url'] == 'https://api.anthropic.com/v1'
assert member.agent_settings['max_iterations'] == 100
assert decrypt_value(member._llm_api_key) == 'shared-external-api-key'
@pytest.mark.asyncio
async def test_store_keeps_openhands_managed_keys_member_specific(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""Managed OpenHands keys should not be copied from one member to everyone else."""
from sqlalchemy import select
from storage.org import Org
from storage.org_member import OrgMember
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
decrypt_value = fixture['decrypt_value']
store = SaasSettingsStore(admin_user_id, mock_config)
new_settings = DataSettings(
llm_model='new-shared-model/gpt-4',
llm_base_url='http://new-shared-url.com',
max_iterations=100,
llm_api_key=SecretStr('new-shared-api-key'),
llm_model='openhands/claude-opus-4-5-20251101',
llm_base_url=LITE_LLM_API_URL,
max_iterations=75,
llm_api_key=SecretStr('admin-managed-api-key'),
)
# Act - call store() with async session
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
with (
patch('storage.saas_settings_store.a_session_maker', async_session_maker),
patch(
'storage.saas_settings_store.LiteLlmManager.verify_existing_key',
new_callable=AsyncMock,
return_value=True,
),
):
await store.store(new_settings)
# Assert - verify ALL org members have the updated LLM settings using sync session
with session_maker() as session:
result = session.execute(select(OrgMember).where(OrgMember.org_id == org_id))
members = result.scalars().all()
org = session.execute(select(Org).where(Org.id == org_id)).scalars().first()
assert org is not None
assert org.agent_settings['llm.model'] == 'openhands/claude-opus-4-5-20251101'
assert org.agent_settings['llm.base_url'] == LITE_LLM_API_URL
assert org.agent_settings['max_iterations'] == 75
# Verify we have all 3 members
assert len(members) == 3, f'Expected 3 org members, got {len(members)}'
members = {
str(member.user_id): member
for member in session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
).scalars().all()
}
assert len(members) == 3
for member in members:
# Verify LLM model is updated
assert (
member.llm_model == 'new-shared-model/gpt-4'
), f'Expected llm_model to be updated for member {member.user_id}'
admin_member = members[admin_user_id]
assert decrypt_value(admin_member._llm_api_key) == 'admin-managed-api-key'
# Verify LLM base URL is updated
assert (
member.llm_base_url == 'http://new-shared-url.com'
), f'Expected llm_base_url to be updated for member {member.user_id}'
member1 = members[str(fixture['member1_user_id'])]
member2 = members[str(fixture['member2_user_id'])]
assert decrypt_value(member1._llm_api_key) == 'member1-initial-key'
assert decrypt_value(member2._llm_api_key) == 'member2-initial-key'
# Verify max_iterations is updated
assert (
member.max_iterations == 100
), f'Expected max_iterations to be 100 for member {member.user_id}'
# Verify the API key is encrypted and decrypts to the correct value
decrypted_key = decrypt_value(member._llm_api_key)
assert (
decrypted_key == 'new-shared-api-key'
), f'Expected llm_api_key to decrypt to new-shared-api-key for member {member.user_id}'
for member in members.values():
assert member.agent_settings['llm.model'] == 'openhands/claude-opus-4-5-20251101'
assert member.agent_settings['llm.base_url'] == LITE_LLM_API_URL
assert member.agent_settings['max_iterations'] == 75
@pytest.mark.asyncio
async def test_store_updates_org_default_llm_settings(
async def test_store_saves_mcp_config_to_current_member_only(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When admin saves LLM settings, org's default_llm_model/base_url/max_iterations should be updated.
This test verifies that the Org table's default settings are updated so that
new members joining later will inherit the correct LLM configuration.
"""
from sqlalchemy import select
from storage.org import Org
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
new_settings = DataSettings(
llm_model='anthropic/claude-sonnet-4',
llm_base_url='https://api.anthropic.com/v1',
max_iterations=75,
llm_api_key=SecretStr('test-api-key'),
)
# Act
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert - verify org's default fields were updated
with session_maker() as session:
result = session.execute(select(Org).where(Org.id == org_id))
org = result.scalars().first()
assert org is not None
assert org.default_llm_model == 'anthropic/claude-sonnet-4'
assert org.default_llm_base_url == 'https://api.anthropic.com/v1'
assert org.default_max_iterations == 75
@pytest.mark.asyncio
async def test_store_saves_mcp_config_to_user_org_member_only(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When user saves MCP config, it should be stored ONLY on their org_member, not propagated to others.
This test verifies that MCP settings are user-specific:
1. The saving user's org_member.mcp_config is set
2. Other members' org_member.mcp_config remains unchanged (NULL)
"""
from sqlalchemy import select
from storage.org_member import OrgMember
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = fixture['member1_user_id']
member2_user_id = fixture['member2_user_id']
member1_user_id = str(fixture['member1_user_id'])
member2_user_id = str(fixture['member2_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
user_mcp_config = {
'sse_servers': [{'url': 'https://user1-mcp-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
new_settings = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=user_mcp_config,
)
# Act
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert
with session_maker() as session:
result = session.execute(select(OrgMember).where(OrgMember.org_id == org_id))
members = {str(m.user_id): m for m in result.scalars().all()}
# Admin's mcp_config should be set
assert members[admin_user_id].mcp_config == user_mcp_config
# Other members' mcp_config should remain NULL (not propagated)
assert members[str(member1_user_id)].mcp_config is None
assert members[str(member2_user_id)].mcp_config is None
@pytest.mark.asyncio
async def test_store_does_not_update_org_mcp_config(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When user saves MCP config, org.mcp_config should NOT be updated.
MCP settings are user-specific and should be stored on org_member, not org.
"""
from sqlalchemy import select
from storage.org import Org
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
store = SaasSettingsStore(admin_user_id, mock_config)
user_mcp_config = {
'sse_servers': [{'url': 'https://private-mcp-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
new_settings = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_api_key=SecretStr('test-api-key'),
mcp_config=user_mcp_config,
)
# Act
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert - org.mcp_config should remain NULL
with session_maker() as session:
result = session.execute(select(Org).where(Org.id == org_id))
org = result.scalars().first()
org = session.execute(select(Org).where(Org.id == org_id)).scalars().first()
assert org is not None
assert org.mcp_config is None
assert org.agent_settings.get('mcp_config') is None
members = {
str(m.user_id): m
for m in session.execute(
select(OrgMember).where(OrgMember.org_id == org_id)
).scalars().all()
}
assert members[admin_user_id].mcp_config == user_mcp_config
assert members[member1_user_id].mcp_config is None
assert members[member2_user_id].mcp_config is None
assert members[admin_user_id].agent_settings.get('mcp_config') is None
assert members[member1_user_id].agent_settings.get('mcp_config') is None
assert members[member2_user_id].agent_settings.get('mcp_config') is None
@pytest.mark.asyncio
async def test_load_returns_user_specific_mcp_config(
async def test_store_does_not_overwrite_other_members_mcp_config(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When loading settings, mcp_config should come from the user's org_member, not from org or other members.
from sqlalchemy import select
from storage.org_member import OrgMember
This test verifies user isolation:
1. User1 stores their MCP config
2. User2 stores a different MCP config
3. Loading as User1 returns User1's config (not User2's)
"""
# Arrange
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = str(fixture['member1_user_id'])
user1_mcp_config = {
'sse_servers': [{'url': 'https://user1-private-server.com', 'api_key': None}],
admin_store = SaasSettingsStore(admin_user_id, mock_config)
member_store = SaasSettingsStore(member1_user_id, mock_config)
admin_mcp_config = {
'sse_servers': [{'url': 'https://admin-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
user2_mcp_config = {
'sse_servers': [{'url': 'https://user2-private-server.com', 'api_key': None}],
member_mcp_config = {
'sse_servers': [{'url': 'https://member-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
# Store MCP config for user1 (admin)
store1 = SaasSettingsStore(admin_user_id, mock_config)
settings1 = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_api_key=SecretStr('test-api-key'),
mcp_config=user1_mcp_config,
)
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store1.store(settings1)
await admin_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=admin_mcp_config,
)
)
await member_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=member_mcp_config,
)
)
with session_maker() as session:
members = {
str(m.user_id): m
for m in session.execute(select(OrgMember)).scalars().all()
}
assert members[admin_user_id].mcp_config == admin_mcp_config
assert members[member1_user_id].mcp_config == member_mcp_config
@pytest.mark.asyncio
async def test_load_returns_current_member_specific_mcp_config(
async_session_maker, mock_config, org_with_multiple_members_fixture
):
fixture = org_with_multiple_members_fixture
admin_user_id = str(fixture['admin_user_id'])
member1_user_id = str(fixture['member1_user_id'])
admin_mcp_config = {
'sse_servers': [{'url': 'https://admin-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
member_mcp_config = {
'sse_servers': [{'url': 'https://member-private-server.com', 'api_key': None}],
'stdio_servers': [],
'shttp_servers': [],
}
admin_store = SaasSettingsStore(admin_user_id, mock_config)
member_store = SaasSettingsStore(member1_user_id, mock_config)
# Store different MCP config for user2 (member1)
store2 = SaasSettingsStore(member1_user_id, mock_config)
settings2 = DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com', # Non-LiteLLM URL to skip API key verification
llm_api_key=SecretStr('test-api-key'),
mcp_config=user2_mcp_config,
)
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store2.store(settings2)
await admin_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=admin_mcp_config,
)
)
await member_store.store(
DataSettings(
llm_model='test-model',
llm_base_url='http://non-litellm-url.com',
llm_api_key=SecretStr('test-api-key'),
mcp_config=member_mcp_config,
)
)
# Act - load settings as user1
# Need to patch all store modules since load() calls UserStore, OrgStore, etc.
with patch(
'storage.saas_settings_store.a_session_maker', async_session_maker
), patch('storage.user_store.a_session_maker', async_session_maker), patch(
'storage.org_store.a_session_maker', async_session_maker
):
loaded_settings = await store1.load()
admin_loaded_settings = await admin_store.load()
member_loaded_settings = await member_store.load()
# Assert - user1 should see their own MCP config, not user2's
assert loaded_settings is not None
assert loaded_settings.mcp_config is not None
assert (
loaded_settings.mcp_config.sse_servers[0].url
== 'https://user1-private-server.com'
)
assert admin_loaded_settings is not None
assert admin_loaded_settings.mcp_config is not None
assert admin_loaded_settings.mcp_config.sse_servers[0].url == 'https://admin-private-server.com'
assert member_loaded_settings is not None
assert member_loaded_settings.mcp_config is not None
assert member_loaded_settings.mcp_config.sse_servers[0].url == 'https://member-private-server.com'

View File

@@ -52,6 +52,7 @@ def test_get_kwargs_from_settings():
settings = Settings(
language='es',
enable_sound_notifications=True,
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test-key'),
)
@@ -83,6 +84,7 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
# Mock LiteLlmManager.create_entries to return a Settings object
mock_settings = Settings(
language='en',
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('test_api_key'),
llm_base_url='http://test.url',
agent='CodeActAgent',
@@ -97,8 +99,11 @@ async def test_create_default_settings_with_litellm(mock_litellm_api):
# With mock, should return settings with API key from LiteLLM
assert settings is not None
assert settings.llm_api_key.get_secret_value() == 'test_api_key'
assert settings.llm_base_url == 'http://test.url'
assert (
settings.get_secret_agent_setting('llm.api_key').get_secret_value()
== 'test_api_key'
)
assert settings.get_agent_setting('llm.base_url') == 'http://test.url'
@pytest.mark.asyncio
@@ -688,8 +693,10 @@ def test_has_custom_settings_custom_base_url():
user_settings = UserSettings(
keycloak_user_id='test',
llm_base_url='https://custom.api.example.com',
llm_model='some-model',
agent_settings={
'llm.base_url': 'https://custom.api.example.com',
'llm.model': 'some-model',
},
)
result = UserStore._has_custom_settings(user_settings, old_user_version=1)
@@ -701,11 +708,7 @@ def test_has_custom_settings_no_model():
"""Test that no model set means using defaults."""
from storage.user_settings import UserSettings
user_settings = UserSettings(
keycloak_user_id='test',
llm_base_url=None,
llm_model=None,
)
user_settings = UserSettings(keycloak_user_id='test', agent_settings={})
result = UserStore._has_custom_settings(user_settings, old_user_version=1)
@@ -718,8 +721,7 @@ def test_has_custom_settings_empty_model():
user_settings = UserSettings(
keycloak_user_id='test',
llm_base_url=None,
llm_model=' ', # whitespace only
agent_settings={'llm.model': ' '},
)
result = UserStore._has_custom_settings(user_settings, old_user_version=1)
@@ -727,6 +729,35 @@ def test_has_custom_settings_empty_model():
assert result is False
def test_user_settings_byor_secret_property_encrypts_round_trip():
from storage.user_settings import UserSettings
user_settings = UserSettings(keycloak_user_id='test')
user_settings.llm_api_key_for_byor_secret = SecretStr('sk-byor-secret')
assert user_settings.llm_api_key_for_byor != 'sk-byor-secret'
assert user_settings.llm_api_key_for_byor_secret is not None
assert (
user_settings.llm_api_key_for_byor_secret.get_secret_value() == 'sk-byor-secret'
)
def test_user_settings_byor_secret_property_accepts_plaintext_legacy_rows():
from storage.user_settings import UserSettings
user_settings = UserSettings(
keycloak_user_id='test',
llm_api_key_for_byor='sk-legacy-plaintext',
)
assert user_settings.llm_api_key_for_byor_secret is not None
assert (
user_settings.llm_api_key_for_byor_secret.get_secret_value()
== 'sk-legacy-plaintext'
)
# --- Tests for _create_user_settings_from_entities ---
@@ -737,10 +768,12 @@ def test_create_user_settings_from_entities():
# Create mock entities
org_member = MagicMock()
org_member.llm_api_key = SecretStr('test-api-key')
org_member.llm_api_key_for_byor = None
org_member.llm_model = 'claude-3-5-sonnet'
org_member.llm_base_url = 'https://api.example.com'
org_member.max_iterations = 50
org_member.agent_settings = {
'schema_version': 1,
'llm.model': 'claude-3-5-sonnet',
'llm.base_url': 'https://api.example.com',
'max_iterations': 50,
}
user = MagicMock()
user.accepted_tos = None
@@ -753,26 +786,22 @@ def test_create_user_settings_from_entities():
user.git_user_email = 'test@git.com'
org = MagicMock()
org.agent = 'CodeActAgent'
org.security_analyzer = 'mock-analyzer'
org.confirmation_mode = False
org.remote_runtime_resource_factor = 1.0
org.enable_default_condenser = True
org.billing_margin = 0.0
org.enable_proactive_conversation_starters = True
org.sandbox_base_container_image = None
org.sandbox_runtime_container_image = None
org.org_version = 1
org.mcp_config = None
org.agent_settings = {
'schema_version': 1,
'agent': 'CodeActAgent',
'verification.security_analyzer': 'mock-analyzer',
}
org.search_api_key = None
org.sandbox_api_key = None
org.max_budget_per_task = None
org.enable_solvability_analysis = False
org.v1_enabled = True
org.condenser_max_size = None
org.default_llm_model = 'default-model'
org.default_llm_base_url = 'https://default.api.com'
org.default_max_iterations = 100
result = UserStore._create_user_settings_from_entities(
user_id, org_member, user, org
@@ -780,7 +809,11 @@ def test_create_user_settings_from_entities():
assert result.keycloak_user_id == user_id
assert result.llm_api_key == 'test-api-key'
assert result.llm_model == 'claude-3-5-sonnet'
assert result.agent_settings['llm.model'] == 'claude-3-5-sonnet'
assert result.agent_settings['llm.base_url'] == 'https://api.example.com'
assert result.agent_settings['max_iterations'] == 50
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['verification.security_analyzer'] == 'mock-analyzer'
assert result.language == 'en'
assert result.email == 'test@example.com'
@@ -792,10 +825,7 @@ def test_create_user_settings_from_entities_with_org_fallback():
# Create mock entities with None in OrgMember
org_member = MagicMock()
org_member.llm_api_key = None
org_member.llm_api_key_for_byor = None
org_member.llm_model = None # Should fall back to org.default_llm_model
org_member.llm_base_url = None # Should fall back to org.default_llm_base_url
org_member.max_iterations = None # Should fall back to org.default_max_iterations
org_member.agent_settings = {}
user = MagicMock()
user.accepted_tos = None
@@ -808,36 +838,40 @@ def test_create_user_settings_from_entities_with_org_fallback():
user.git_user_email = None
org = MagicMock()
org.agent = 'CodeActAgent'
org.security_analyzer = None
org.confirmation_mode = True
org.remote_runtime_resource_factor = 2.0
org.enable_default_condenser = False
org.billing_margin = 0.1
org.enable_proactive_conversation_starters = False
org.sandbox_base_container_image = 'custom-image'
org.sandbox_runtime_container_image = None
org.org_version = 2
org.mcp_config = {'key': 'value'}
org.agent_settings = {
'schema_version': 1,
'agent': 'CodeActAgent',
'llm.model': 'default-model',
'llm.base_url': 'https://default.api.com',
'verification.confirmation_mode': True,
'condenser.enabled': False,
'condenser.max_size': 1000,
'max_iterations': 100,
'mcp_config': {'key': 'value'},
}
org.search_api_key = SecretStr('search-key')
org.sandbox_api_key = None
org.max_budget_per_task = 10.0
org.enable_solvability_analysis = True
org.v1_enabled = False
org.condenser_max_size = 1000
# Org defaults
org.default_llm_model = 'default-model'
org.default_llm_base_url = 'https://default.api.com'
org.default_max_iterations = 100
result = UserStore._create_user_settings_from_entities(
user_id, org_member, user, org
)
# Should have fallen back to org defaults
assert result.llm_model == 'default-model'
assert result.llm_base_url == 'https://default.api.com'
assert result.max_iterations == 100
assert result.agent_settings['llm.model'] == 'default-model'
assert result.agent_settings['llm.base_url'] == 'https://default.api.com'
assert result.agent_settings['max_iterations'] == 100
assert result.agent_settings['agent'] == 'CodeActAgent'
assert result.agent_settings['verification.confirmation_mode'] is True
assert result.agent_settings['condenser.max_size'] == 1000
assert result.language == 'es'
assert result.search_api_key == 'search-key'

View File

@@ -106,7 +106,6 @@ const createMockUser = (
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
...overrides,
@@ -615,7 +614,6 @@ describe("UserContextMenu", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -651,7 +649,6 @@ describe("UserContextMenu", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});

View File

@@ -8,6 +8,7 @@ import V1ConversationService from "#/api/conversation-service/v1-conversation-se
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import SettingsService from "#/api/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
// Mock the agent state hook
vi.mock("#/hooks/use-agent-state", () => ({
@@ -115,6 +116,11 @@ describe("useConversationSkills - V1 API Integration", () => {
},
];
const buildSettings = (v1Enabled: boolean) => ({
...DEFAULT_SETTINGS,
v1_enabled: v1Enabled,
});
beforeEach(() => {
vi.clearAllMocks();
@@ -135,27 +141,9 @@ describe("useConversationSkills - V1 API Integration", () => {
.spyOn(ConversationService, "getMicroagents")
.mockResolvedValue({ microagents: mockMicroagents });
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: false,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings(false),
);
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
@@ -172,27 +160,9 @@ describe("useConversationSkills - V1 API Integration", () => {
microagents: mockMicroagents,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: false,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings(false),
);
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
@@ -210,27 +180,9 @@ describe("useConversationSkills - V1 API Integration", () => {
.spyOn(V1ConversationService, "getSkills")
.mockResolvedValue({ skills: mockSkills });
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings(true),
);
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
@@ -247,27 +199,9 @@ describe("useConversationSkills - V1 API Integration", () => {
skills: mockSkills,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings(true),
);
// Act
renderWithProviders(<SkillsModal onClose={vi.fn()} />);
@@ -279,27 +213,9 @@ describe("useConversationSkills - V1 API Integration", () => {
it("should use v1 API when v1_enabled is true", async () => {
// Arrange
vi.spyOn(SettingsService, "getSettings").mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
vi.spyOn(SettingsService, "getSettings").mockResolvedValue(
buildSettings(true),
);
const getSkillsSpy = vi
.spyOn(V1ConversationService, "getSkills")
@@ -329,27 +245,7 @@ describe("useConversationSkills - V1 API Integration", () => {
const settingsSpy = vi
.spyOn(SettingsService, "getSettings")
.mockResolvedValue({
v1_enabled: false,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
.mockResolvedValue(buildSettings(false));
// Act - Initial render with v1_enabled: false
const { rerender } = renderWithProviders(
@@ -361,27 +257,7 @@ describe("useConversationSkills - V1 API Integration", () => {
expect(getMicroagentsSpy).toHaveBeenCalledWith(conversationId);
// Arrange - Change settings to v1_enabled: true
settingsSpy.mockResolvedValue({
v1_enabled: true,
llm_model: "test-model",
llm_base_url: "",
agent: "test-agent",
language: "en",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
confirmation_mode: false,
security_analyzer: null,
remote_runtime_resource_factor: null,
provider_tokens_set: {},
enable_default_condenser: false,
condenser_max_size: null,
enable_sound_notifications: false,
enable_proactive_conversation_starters: false,
enable_solvability_analysis: false,
user_consents_to_analytics: null,
max_budget_per_task: null,
});
settingsSpy.mockResolvedValue(buildSettings(true));
// Act - Force re-render
rerender(<SkillsModal onClose={vi.fn()} />);

View File

@@ -6,6 +6,7 @@ import { screen } from "@testing-library/react";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { getAgentSettingValue } from "#/utils/sdk-settings-schema";
describe("SettingsForm", () => {
const onCloseMock = vi.fn();
@@ -16,7 +17,9 @@ describe("SettingsForm", () => {
Component: () => (
<SettingsForm
settings={DEFAULT_SETTINGS}
models={[DEFAULT_SETTINGS.llm_model]}
models={[
String(getAgentSettingValue(DEFAULT_SETTINGS, "llm.model") ?? ""),
]}
onClose={onCloseMock}
/>
),
@@ -33,7 +36,7 @@ describe("SettingsForm", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: DEFAULT_SETTINGS.llm_model,
"llm.model": getAgentSettingValue(DEFAULT_SETTINGS, "llm.model"),
}),
);
});

View File

@@ -10,7 +10,7 @@ describe("useSaveSettings", () => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
it("should preserve canonical llm.api_key payload values", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => (
@@ -20,20 +20,20 @@ describe("useSaveSettings", () => {
),
});
result.current.mutate({ llm_api_key: "" });
result.current.mutate({ "llm.api_key": "" });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "",
"llm.api_key": "",
}),
);
});
result.current.mutate({ llm_api_key: null });
result.current.mutate({ "llm.api_key": null });
await waitFor(() => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: undefined,
"llm.api_key": null,
}),
);
});

View File

@@ -35,21 +35,13 @@ function createMinimalOrg(
contact_name: "",
contact_email: "",
conversation_expiration: 0,
agent: "",
default_max_iterations: 0,
security_analyzer: "",
confirmation_mode: false,
default_llm_model: "",
default_llm_api_key_for_byor: "",
default_llm_base_url: "",
remote_runtime_resource_factor: 0,
enable_default_condenser: false,
billing_margin: 0,
enable_proactive_conversation_starters: false,
sandbox_base_container_image: "",
sandbox_runtime_container_image: "",
org_version: 0,
mcp_config: { tools: [], settings: {} },
agent_settings: {},
search_api_key: null,
sandbox_api_key: null,
max_budget_per_task: 0,

View File

@@ -76,7 +76,6 @@ describe("Billing Route", () => {
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
...overrides,

File diff suppressed because it is too large Load Diff

View File

@@ -111,7 +111,6 @@ describe("Manage Org Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
@@ -123,7 +122,6 @@ describe("Manage Org Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
@@ -138,7 +136,6 @@ describe("Manage Org Route", () => {
llm_api_key: string;
max_iterations: number;
llm_model: string;
llm_api_key_for_byor: string | null;
llm_base_url: string;
status: "active" | "invited" | "inactive";
}) => {
@@ -456,7 +453,6 @@ describe("Manage Org Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -482,7 +478,6 @@ describe("Manage Org Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});

View File

@@ -133,7 +133,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -225,7 +224,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: string;
max_iterations: number;
llm_model: string;
llm_api_key_for_byor: string | null;
llm_base_url: string;
status: "active" | "invited" | "inactive";
},
@@ -253,7 +251,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: string;
max_iterations: number;
llm_model: string;
llm_api_key_for_byor: string | null;
llm_base_url: string;
status: "active" | "invited" | "inactive";
},
@@ -376,7 +373,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
@@ -435,7 +431,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
};
@@ -480,7 +475,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
@@ -492,7 +486,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
@@ -514,7 +507,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -551,7 +543,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
@@ -661,7 +652,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "invited",
},
@@ -708,7 +698,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -731,7 +720,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
};
@@ -776,7 +764,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
};
@@ -821,7 +808,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
@@ -833,7 +819,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
@@ -855,7 +840,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -891,7 +875,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
@@ -928,7 +911,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
@@ -964,7 +946,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
},
@@ -1006,7 +987,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},
@@ -1036,7 +1016,6 @@ describe("Manage Organization Members Route", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active" as const,
},

View File

@@ -80,7 +80,6 @@ describe("clientLoader permission checks", () => {
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
...overrides,

View File

@@ -70,7 +70,6 @@ describe("Settings Screen", () => {
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
...overrides,
@@ -266,7 +265,6 @@ describe("Settings Screen", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -307,7 +305,6 @@ describe("Settings Screen", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -346,7 +343,6 @@ describe("Settings Screen", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -410,7 +406,6 @@ describe("Settings Screen", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -466,7 +461,6 @@ describe("Settings Screen", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});
@@ -602,7 +596,6 @@ describe("Settings Screen", () => {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
status: "active",
});

View File

@@ -12,11 +12,14 @@ describe("hasAdvancedSettingsSet", () => {
});
describe("should be true if", () => {
test("llm_base_url is set", () => {
test("llm.base_url is set", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
llm_base_url: "test",
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
"llm.base_url": "test",
},
}),
).toBe(true);
});
@@ -25,16 +28,22 @@ describe("hasAdvancedSettingsSet", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
agent: "test",
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
agent: "test",
},
}),
).toBe(true);
});
test("enable_default_condenser is disabled", () => {
test("condenser.enabled is disabled", () => {
// Arrange
const settings = {
...DEFAULT_SETTINGS,
enable_default_condenser: false,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
"condenser.enabled": false,
},
};
// Act
@@ -44,11 +53,14 @@ describe("hasAdvancedSettingsSet", () => {
expect(result).toBe(true);
});
test("condenser_max_size is customized above default", () => {
test("condenser.max_size is customized above default", () => {
// Arrange
const settings = {
...DEFAULT_SETTINGS,
condenser_max_size: 200,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
"condenser.max_size": 200,
},
};
// Act
@@ -58,11 +70,14 @@ describe("hasAdvancedSettingsSet", () => {
expect(result).toBe(true);
});
test("condenser_max_size is customized below default", () => {
test("condenser.max_size is customized below default", () => {
// Arrange
const settings = {
...DEFAULT_SETTINGS,
condenser_max_size: 50,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
"condenser.max_size": 50,
},
};
// Act

View File

@@ -13,7 +13,7 @@ describe("Model name case preservation", () => {
const settings = extractSettings(formData);
// Test that model names maintain their original casing
expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
expect(settings["llm.model"]).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
});
it("should preserve openai model case", () => {
@@ -24,7 +24,7 @@ describe("Model name case preservation", () => {
formData.set("language", "en");
const settings = extractSettings(formData);
expect(settings.llm_model).toBe("openai/gpt-4o");
expect(settings["llm.model"]).toBe("openai/gpt-4o");
});
it("should preserve anthropic model case", () => {
@@ -35,7 +35,7 @@ describe("Model name case preservation", () => {
formData.set("language", "en");
const settings = extractSettings(formData);
expect(settings.llm_model).toBe("anthropic/claude-sonnet-4-20250514");
expect(settings["llm.model"]).toBe("anthropic/claude-sonnet-4-20250514");
});
it("should not automatically lowercase model names", () => {
@@ -48,7 +48,9 @@ describe("Model name case preservation", () => {
const settings = extractSettings(formData);
// Test that camelCase and PascalCase are preserved
expect(settings.llm_model).not.toBe("sambanova/meta-llama-3.1-8b-instruct");
expect(settings.llm_model).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
expect(settings["llm.model"]).not.toBe(
"sambanova/meta-llama-3.1-8b-instruct",
);
expect(settings["llm.model"]).toBe("SambaNova/Meta-Llama-3.1-8B-Instruct");
});
});

View File

@@ -37,7 +37,6 @@ const mockUser: OrganizationMember = {
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
};

View File

@@ -70,7 +70,6 @@ describe("createPermissionGuard", () => {
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
});
@@ -93,7 +92,6 @@ describe("createPermissionGuard", () => {
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
});
@@ -144,7 +142,6 @@ describe("createPermissionGuard", () => {
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
});

View File

@@ -67,15 +67,15 @@ describe("extractSettings", () => {
// Verify that the model name case is preserved
const expectedModel = `${provider}/${model}`;
expect(settings.llm_model).toBe(expectedModel);
expect(settings["llm.model"]).toBe(expectedModel);
// Only test that it's not lowercased if the original has uppercase letters
if (expectedModel !== expectedModel.toLowerCase()) {
expect(settings.llm_model).not.toBe(expectedModel.toLowerCase());
expect(settings["llm.model"]).not.toBe(expectedModel.toLowerCase());
}
});
});
it("should handle custom model without lowercasing", () => {
it("should preserve selected model case and ignore unsupported custom-model inputs", () => {
const formData = new FormData();
formData.set("llm-provider-input", "sambanova");
formData.set("llm-model-input", "Meta-Llama-3.1-8B-Instruct");
@@ -84,8 +84,7 @@ describe("extractSettings", () => {
const settings = extractSettings(formData);
// Custom model should take precedence and preserve case
expect(settings.llm_model).toBe("Custom-Model-Name");
expect(settings.llm_model).not.toBe("custom-model-name");
expect(settings["llm.model"]).toBe("sambanova/Meta-Llama-3.1-8B-Instruct");
expect(settings["llm.model"]).not.toBe("custom-model-name");
});
});

View File

@@ -17,7 +17,9 @@ class SettingsService {
* Save the settings to the server. Only valid settings are saved.
* @param settings - the settings to save
*/
static async saveSettings(settings: Partial<Settings>): Promise<boolean> {
static async saveSettings(
settings: Partial<Settings> & Record<string, unknown>,
): Promise<boolean> {
const data = await openHands.post("/api/settings", settings);
return data.status === 200;
}

View File

@@ -3,13 +3,17 @@ import { Tooltip } from "@heroui/react";
import { I18nKey } from "#/i18n/declaration";
import LockIcon from "#/icons/lock.svg?react";
import { useSettings } from "#/hooks/query/use-settings";
import { getAgentSettingValue } from "#/utils/sdk-settings-schema";
function ConfirmationModeEnabled() {
const { t } = useTranslation();
const { data: settings } = useSettings();
const confirmationModeEnabled =
settings &&
getAgentSettingValue(settings, "verification.confirmation_mode") === true;
if (!settings?.confirmation_mode) {
if (!confirmationModeEnabled) {
return null;
}

View File

@@ -0,0 +1,167 @@
import React from "react";
import { OptionalTag } from "#/components/features/settings/optional-tag";
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { SettingsFieldSchema } from "#/types/settings";
import { HelpLink } from "#/ui/help-link";
import { Typography } from "#/ui/typography";
import { cn } from "#/utils/utils";
// ---------------------------------------------------------------------------
// Help links UI-only mapping from field keys to user-facing guidance.
// ---------------------------------------------------------------------------
export const FIELD_HELP_LINKS: Record<
string,
{ text: string; linkText: string; href: string }
> = {
"llm.api_key": {
text: "Don't know your API key?",
linkText: "Click here for instructions.",
href: "https://docs.all-hands.dev/usage/local-setup#getting-an-api-key",
},
};
function FieldHelp({ field }: { field: SettingsFieldSchema }) {
const helpLink = FIELD_HELP_LINKS[field.key];
return (
<>
{field.description ? (
<Typography.Paragraph className="text-tertiary-alt text-xs leading-5">
{field.description}
</Typography.Paragraph>
) : null}
{helpLink ? (
<HelpLink
testId={`help-link-${field.key}`}
text={helpLink.text}
linkText={helpLink.linkText}
href={helpLink.href}
size="settings"
linkColor="white"
/>
) : null}
</>
);
}
function isSelectField(field: SettingsFieldSchema): boolean {
return field.choices.length > 0;
}
function isBooleanField(field: SettingsFieldSchema): boolean {
return field.value_type === "boolean" && !isSelectField(field);
}
function isJsonField(field: SettingsFieldSchema): boolean {
return field.value_type === "array" || field.value_type === "object";
}
function getInputType(
field: SettingsFieldSchema,
): React.HTMLInputTypeAttribute {
if (field.secret) {
return "password";
}
if (field.value_type === "integer" || field.value_type === "number") {
return "number";
}
return "text";
}
export function SchemaField({
field,
value,
isDisabled,
onChange,
}: {
field: SettingsFieldSchema;
value: string | boolean;
isDisabled: boolean;
onChange: (value: string | boolean) => void;
}) {
if (isBooleanField(field)) {
return (
<div className="flex flex-col gap-1.5">
<SettingsSwitch
testId={`sdk-settings-${field.key}`}
isToggled={Boolean(value)}
isDisabled={isDisabled}
onToggle={onChange}
>
{field.label}
</SettingsSwitch>
<FieldHelp field={field} />
</div>
);
}
if (isSelectField(field)) {
return (
<div className="flex flex-col gap-1.5">
<SettingsDropdownInput
testId={`sdk-settings-${field.key}`}
name={field.key}
label={field.label}
items={field.choices.map((choice) => ({
key: String(choice.value),
label: choice.label,
}))}
selectedKey={value === "" ? undefined : String(value)}
isClearable={!field.required}
required={field.required}
showOptionalTag={!field.required}
isDisabled={isDisabled}
onSelectionChange={(selectedKey) =>
onChange(String(selectedKey ?? ""))
}
/>
<FieldHelp field={field} />
</div>
);
}
if (isJsonField(field)) {
return (
<label className="flex flex-col gap-2.5 w-full">
<div className="flex items-center gap-2">
<span className="text-sm">{field.label}</span>
{!field.required ? <OptionalTag /> : null}
</div>
<textarea
data-testid={`sdk-settings-${field.key}`}
name={field.key}
value={String(value ?? "")}
required={field.required}
disabled={isDisabled}
onChange={(event) => onChange(event.target.value)}
className={cn(
"bg-tertiary border border-[#717888] min-h-32 w-full rounded-sm p-2 font-mono text-sm",
"placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
<FieldHelp field={field} />
</label>
);
}
return (
<div className="flex flex-col gap-1.5">
<SettingsInput
testId={`sdk-settings-${field.key}`}
name={field.key}
label={field.label}
type={getInputType(field)}
value={String(value ?? "")}
required={field.required}
showOptionalTag={!field.required}
isDisabled={isDisabled}
onChange={onChange}
className="w-full"
/>
<FieldHelp field={field} />
</div>
);
}

View File

@@ -0,0 +1,259 @@
import React from "react";
import { AxiosError } from "axios";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { usePermission } from "#/hooks/organizations/use-permissions";
import { useConfig } from "#/hooks/query/use-config";
import { useMe } from "#/hooks/query/use-me";
import { useSettings } from "#/hooks/query/use-settings";
import { I18nKey } from "#/i18n/declaration";
import { Typography } from "#/ui/typography";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import {
buildInitialSettingsFormValues,
buildSdkSettingsPayload,
getVisibleSettingsSections,
hasAdvancedSettings,
hasMinorSettings,
inferInitialView,
SettingsDirtyState,
SettingsFormValues,
type SettingsView,
} from "#/utils/sdk-settings-schema";
import { SchemaField } from "./schema-field";
// ---------------------------------------------------------------------------
// View tier toggle
// ---------------------------------------------------------------------------
function ViewToggle({
view,
setView,
showAdvanced,
showAll,
}: {
view: SettingsView;
setView: (v: SettingsView) => void;
showAdvanced: boolean;
showAll: boolean;
}) {
const { t } = useTranslation();
if (!showAdvanced && !showAll) return null;
return (
<div className="flex items-center gap-2 mb-6">
<BrandButton
testId="sdk-section-basic-toggle"
variant={view === "basic" ? "primary" : "secondary"}
type="button"
onClick={() => setView("basic")}
>
{t(I18nKey.SETTINGS$BASIC)}
</BrandButton>
{showAdvanced ? (
<BrandButton
testId="sdk-section-advanced-toggle"
variant={view === "advanced" ? "primary" : "secondary"}
type="button"
onClick={() => setView("advanced")}
>
{t(I18nKey.SETTINGS$ADVANCED)}
</BrandButton>
) : null}
{showAll ? (
<BrandButton
testId="sdk-section-all-toggle"
variant={view === "all" ? "primary" : "secondary"}
type="button"
onClick={() => setView("all")}
>
{t(I18nKey.SETTINGS$ALL)}
</BrandButton>
) : null}
</div>
);
}
export interface SdkSectionHeaderProps {
values: SettingsFormValues;
isDisabled: boolean;
onChange: (key: string, value: string | boolean) => void;
}
/**
* A generic SDK-schemadriven settings page that renders fields
* from one or more schema sections.
*
* @param sectionKeys - which schema section(s) this page owns (e.g. ["condenser"])
* @param excludeKeys - field keys to skip (rendered elsewhere by the caller)
* @param header - optional render prop receiving shared state to render above fields
* @param testId - data-testid for the page wrapper
*/
export function SdkSectionPage({
sectionKeys,
excludeKeys = new Set<string>(),
header,
testId = "sdk-section-settings-screen",
}: {
sectionKeys: string[];
excludeKeys?: Set<string>;
header?: (props: SdkSectionHeaderProps) => React.ReactNode;
testId?: string;
}) {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: settings, isLoading, isFetching } = useSettings();
const { data: config } = useConfig();
const { data: me } = useMe();
const { hasPermission } = usePermission(me?.role ?? "member");
const isOssMode = config?.app_mode === "oss";
const isReadOnly = isOssMode ? false : !hasPermission("edit_llm_settings");
const [view, setView] = React.useState<SettingsView>("basic");
const [values, setValues] = React.useState<SettingsFormValues>({});
const [dirty, setDirty] = React.useState<SettingsDirtyState>({});
const fullSchema = settings?.agent_settings_schema ?? null;
// Build a filtered schema containing only the requested sections
const filteredSchema = React.useMemo(() => {
if (!fullSchema) return null;
const sectionSet = new Set(sectionKeys);
return {
...fullSchema,
sections: fullSchema.sections.filter((s) => sectionSet.has(s.key)),
};
}, [fullSchema, sectionKeys]);
const showAdvanced = hasAdvancedSettings(filteredSchema);
const showAll = hasMinorSettings(filteredSchema);
React.useEffect(() => {
if (!settings?.agent_settings_schema) return;
setValues(buildInitialSettingsFormValues(settings));
setDirty({});
setView(inferInitialView(settings, filteredSchema));
}, [settings, filteredSchema]);
const visibleSections = React.useMemo(() => {
if (!filteredSchema) return [];
return getVisibleSettingsSections(
filteredSchema,
values,
view,
excludeKeys,
);
}, [filteredSchema, values, view, excludeKeys]);
const handleFieldChange = React.useCallback(
(fieldKey: string, nextValue: string | boolean) => {
setValues((prev) => ({ ...prev, [fieldKey]: nextValue }));
setDirty((prev) => ({ ...prev, [fieldKey]: true }));
},
[],
);
const handleError = React.useCallback(
(error: AxiosError) => {
const msg = retrieveAxiosErrorMessage(error);
displayErrorToast(msg || t(I18nKey.ERROR$GENERIC));
},
[t],
);
const handleSave = () => {
if (!fullSchema || isReadOnly) return;
let payload: ReturnType<typeof buildSdkSettingsPayload>;
try {
payload = buildSdkSettingsPayload(fullSchema, values, dirty);
} catch (error) {
displayErrorToast(
error instanceof Error ? error.message : t(I18nKey.ERROR$GENERIC),
);
return;
}
if (Object.keys(payload).length === 0) return;
saveSettings(payload, {
onError: handleError,
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
setDirty({});
},
});
};
if (isLoading || isFetching) return <LlmSettingsInputsSkeleton />;
if (!filteredSchema || filteredSchema.sections.length === 0) {
return (
<Typography.Paragraph className="text-tertiary-alt">
{t(I18nKey.SETTINGS$SDK_SCHEMA_UNAVAILABLE)}
</Typography.Paragraph>
);
}
if (Object.keys(values).length === 0) return <LlmSettingsInputsSkeleton />;
return (
<div data-testid={testId} className="h-full relative">
<ViewToggle
view={view}
setView={setView}
showAdvanced={showAdvanced}
showAll={showAll}
/>
<div className="flex flex-col gap-8 pb-20">
{header?.({
values,
isDisabled: isReadOnly,
onChange: handleFieldChange,
})}
{visibleSections.map((section) => (
<section key={section.key} className="flex flex-col gap-4">
<div className="grid gap-4 xl:grid-cols-2">
{section.fields.map((field) => (
<SchemaField
key={field.key}
field={field}
value={values[field.key]}
isDisabled={isReadOnly}
onChange={(nextValue) =>
handleFieldChange(field.key, nextValue)
}
/>
))}
</div>
</section>
))}
</div>
{!isReadOnly ? (
<div className="sticky bottom-0 bg-base py-4">
<BrandButton
testId="save-button"
type="button"
variant="primary"
isDisabled={isPending || Object.keys(dirty).length === 0}
onClick={handleSave}
>
{isPending
? t(I18nKey.SETTINGS$SAVING)
: t(I18nKey.SETTINGS$SAVE_CHANGES)}
</BrandButton>
</div>
) : null}
</div>
);
}

View File

@@ -13,6 +13,7 @@ import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { HelpLink } from "#/ui/help-link";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { getAgentSettingValue } from "#/utils/sdk-settings-schema";
import { SETTINGS_FORM } from "#/utils/constants";
interface SettingsFormProps {
@@ -41,8 +42,8 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
onClose();
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.llm_model,
LLM_API_KEY_SET: newSettings.llm_api_key_set ? "SET" : "UNSET",
LLM_MODEL: newSettings["llm.model"],
LLM_API_KEY_SET: newSettings["llm.api_key"] ? "SET" : "UNSET",
SEARCH_API_KEY_SET: newSettings.search_api_key ? "SET" : "UNSET",
REMOTE_RUNTIME_RESOURCE_FACTOR:
newSettings.remote_runtime_resource_factor,
@@ -68,6 +69,7 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
};
const isLLMKeySet = settings.llm_api_key_set;
const currentModel = getAgentSettingValue(settings, "llm.model");
return (
<div>
@@ -80,7 +82,9 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
<div className="flex flex-col gap-[17px]">
<ModelSelector
models={organizeModelsAndProviders(models)}
currentModel={settings.llm_model}
currentModel={
typeof currentModel === "string" ? currentModel : undefined
}
wrapperClassName="!flex-col !gap-[17px]"
labelClassName={SETTINGS_FORM.LABEL_CLASSNAME}
/>

View File

@@ -2,6 +2,8 @@ import { FiUsers, FiBriefcase } from "react-icons/fi";
import CreditCardIcon from "#/icons/credit-card.svg?react";
import KeyIcon from "#/icons/key.svg?react";
import LightbulbIcon from "#/icons/lightbulb.svg?react";
import LockIcon from "#/icons/lock.svg?react";
import MemoryIcon from "#/icons/memory_icon.svg?react";
import ServerProcessIcon from "#/icons/server-process.svg?react";
import SettingsGearIcon from "#/icons/settings-gear.svg?react";
import CircuitIcon from "#/icons/u-circuit.svg?react";
@@ -23,7 +25,6 @@ export interface SettingsNavItem {
}
export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
// Org settings section (Admin/Owner only)
{
icon: <FiBriefcase size={22} />,
to: "/settings/org",
@@ -42,7 +43,6 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
text: "COMMON$LANGUAGE_MODEL_LLM",
section: "org",
},
// Personal settings section
{
icon: <KeyIcon width={22} height={22} />,
to: "/settings/api-keys",
@@ -61,7 +61,18 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
text: "SETTINGS$NAV_MCP",
section: "personal",
},
// User settings section (no header shown)
{
icon: <MemoryIcon width={22} height={22} />,
to: "/settings/condenser",
text: "SETTINGS$NAV_CONDENSER",
section: "personal",
},
{
icon: <LockIcon width={22} height={22} />,
to: "/settings/verification",
text: "SETTINGS$NAV_VERIFICATION",
section: "personal",
},
{
icon: <UserIcon width={22} height={22} />,
to: "/settings/user",
@@ -74,14 +85,12 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
text: "SETTINGS$NAV_APPLICATION",
section: "user",
},
// Billing section (personal orgs only)
{
icon: <CreditCardIcon width={22} height={22} />,
to: "/settings/billing",
text: "SETTINGS$NAV_BILLING",
section: "billing",
},
// Other items
{
icon: <PuzzlePieceIcon width={22} height={22} />,
to: "/settings/integrations",
@@ -102,6 +111,16 @@ export const OSS_NAV_ITEMS: SettingsNavItem[] = [
to: "/settings",
text: "SETTINGS$NAV_LLM",
},
{
icon: <MemoryIcon width={22} height={22} />,
to: "/settings/condenser",
text: "SETTINGS$NAV_CONDENSER",
},
{
icon: <LockIcon width={22} height={22} />,
to: "/settings/verification",
text: "SETTINGS$NAV_VERIFICATION",
},
{
icon: <ServerProcessIcon width={22} height={22} />,
to: "/settings/mcp",

View File

@@ -1,7 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
import {
MCPSHTTPServer,
MCPConfig,
MCPSSEServer,
MCPStdioServer,
} from "#/types/settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -26,13 +31,19 @@ export function useAddMcpServer() {
mutationFn: async (server: MCPServerConfig): Promise<void> => {
if (!settings) return;
const currentConfig = settings.mcp_config || {
const currentConfig = (settings.agent_settings?.mcp_config as
| MCPConfig
| undefined) || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
};
const newConfig = { ...currentConfig };
const newConfig: MCPConfig = {
sse_servers: [...currentConfig.sse_servers],
stdio_servers: [...currentConfig.stdio_servers],
shttp_servers: [...currentConfig.shttp_servers],
};
if (server.type === "sse") {
const sseServer: MCPSSEServer = {
@@ -57,15 +68,12 @@ export function useAddMcpServer() {
newConfig.shttp_servers.push(shttpServer);
}
const apiSettings = {
await SettingsService.saveSettings({
mcp_config: newConfig,
v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);
});
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});

View File

@@ -11,9 +11,16 @@ export function useDeleteMcpServer() {
return useMutation({
mutationFn: async (serverId: string): Promise<void> => {
if (!settings?.mcp_config) return;
const currentConfig = settings?.agent_settings?.mcp_config as
| MCPConfig
| undefined;
if (!currentConfig) return;
const newConfig: MCPConfig = { ...settings.mcp_config };
const newConfig: MCPConfig = {
sse_servers: [...currentConfig.sse_servers],
stdio_servers: [...currentConfig.stdio_servers],
shttp_servers: [...currentConfig.shttp_servers],
};
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
@@ -25,15 +32,12 @@ export function useDeleteMcpServer() {
newConfig.shttp_servers.splice(index, 1);
}
const apiSettings = {
await SettingsService.saveSettings({
mcp_config: newConfig,
v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);
v1_enabled: settings?.v1_enabled,
});
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});

View File

@@ -1,28 +1,54 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { usePostHog } from "posthog-js/react";
import { DEFAULT_SETTINGS } from "#/services/settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { Settings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPConfig, Settings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
const settingsToSave: Partial<Settings> = {
...settings,
agent: settings.agent || DEFAULT_SETTINGS.agent,
language: settings.language || DEFAULT_SETTINGS.language,
llm_api_key:
settings.llm_api_key === ""
? ""
: settings.llm_api_key?.trim() || undefined,
condenser_max_size:
settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size,
search_api_key: settings.search_api_key?.trim() || "",
git_user_name:
settings.git_user_name?.trim() || DEFAULT_SETTINGS.git_user_name,
git_user_email:
settings.git_user_email?.trim() || DEFAULT_SETTINGS.git_user_email,
};
type SettingsUpdate = Partial<Settings> & Record<string, unknown>;
const LEGACY_FLAT_TO_SDK: Record<string, string> = {
agent: "agent",
llm_model: "llm.model",
llm_api_key: "llm.api_key",
llm_base_url: "llm.base_url",
mcp_config: "mcp_config",
confirmation_mode: "verification.confirmation_mode",
security_analyzer: "verification.security_analyzer",
enable_default_condenser: "condenser.enabled",
condenser_max_size: "condenser.max_size",
max_iterations: "max_iterations",
};
const saveSettingsMutationFn = async (settings: SettingsUpdate) => {
const settingsToSave: SettingsUpdate = { ...settings };
delete settingsToSave.agent_settings_schema;
delete settingsToSave.agent_settings;
for (const [legacyKey, sdkKey] of Object.entries(LEGACY_FLAT_TO_SDK)) {
const hasLegacyValue = legacyKey in settingsToSave;
const hasSdkValue = sdkKey in settingsToSave;
if (hasLegacyValue && !hasSdkValue) {
settingsToSave[sdkKey] = settingsToSave[legacyKey];
delete settingsToSave[legacyKey];
}
}
if (typeof settingsToSave["llm.api_key"] === "string") {
const apiKey = settingsToSave["llm.api_key"].trim();
settingsToSave["llm.api_key"] = apiKey === "" ? "" : apiKey;
}
if (typeof settingsToSave.search_api_key === "string") {
settingsToSave.search_api_key = settingsToSave.search_api_key.trim();
}
if (typeof settingsToSave.git_user_name === "string") {
settingsToSave.git_user_name = settingsToSave.git_user_name.trim();
}
if (typeof settingsToSave.git_user_email === "string") {
settingsToSave.git_user_email = settingsToSave.git_user_email.trim();
}
await SettingsService.saveSettings(settingsToSave);
};
@@ -34,28 +60,21 @@ export const useSaveSettings = () => {
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async (settings: Partial<Settings>) => {
const newSettings = { ...currentSettings, ...settings };
mutationFn: async (settings: SettingsUpdate) => {
const nextMcpConfig = settings.mcp_config as MCPConfig | undefined;
const currentMcpConfig = currentSettings?.mcp_config as
| MCPConfig
| undefined;
// Track MCP configuration changes
if (
settings.mcp_config &&
currentSettings?.mcp_config !== settings.mcp_config
) {
const hasMcpConfig = !!settings.mcp_config;
const sseServersCount = settings.mcp_config?.sse_servers?.length || 0;
const stdioServersCount =
settings.mcp_config?.stdio_servers?.length || 0;
// Track MCP configuration usage
if (nextMcpConfig && currentMcpConfig !== nextMcpConfig) {
posthog.capture("mcp_config_updated", {
has_mcp_config: hasMcpConfig,
sse_servers_count: sseServersCount,
stdio_servers_count: stdioServersCount,
has_mcp_config: true,
sse_servers_count: nextMcpConfig.sse_servers?.length || 0,
stdio_servers_count: nextMcpConfig.stdio_servers?.length || 0,
});
}
await saveSettingsMutationFn(newSettings);
await saveSettingsMutationFn(settings);
},
onSuccess: async () => {
await queryClient.invalidateQueries({

View File

@@ -1,7 +1,12 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
import {
MCPSHTTPServer,
MCPConfig,
MCPSSEServer,
MCPStdioServer,
} from "#/types/settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -30,9 +35,16 @@ export function useUpdateMcpServer() {
serverId: string;
server: MCPServerConfig;
}): Promise<void> => {
if (!settings?.mcp_config) return;
const currentConfig = settings?.agent_settings?.mcp_config as
| MCPConfig
| undefined;
if (!currentConfig) return;
const newConfig = { ...settings.mcp_config };
const newConfig: MCPConfig = {
sse_servers: [...currentConfig.sse_servers],
stdio_servers: [...currentConfig.stdio_servers],
shttp_servers: [...currentConfig.shttp_servers],
};
const [serverType, indexStr] = serverId.split("-");
const index = parseInt(indexStr, 10);
@@ -59,15 +71,12 @@ export function useUpdateMcpServer() {
newConfig.shttp_servers[index] = shttpServer;
}
const apiSettings = {
await SettingsService.saveSettings({
mcp_config: newConfig,
v1_enabled: settings.v1_enabled,
};
await SettingsService.saveSettings(apiSettings);
v1_enabled: settings?.v1_enabled,
});
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});

View File

@@ -1,19 +1,82 @@
import { useQuery } from "@tanstack/react-query";
import SettingsService from "#/api/settings-service/settings-service.api";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings, SettingsValue } from "#/types/settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useIsAuthed } from "./use-is-authed";
import { useConfig } from "./use-config";
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
const pickFirstString = (...values: unknown[]): string | undefined =>
values.find(isNonEmptyString);
const pickFirstBoolean = (...values: unknown[]): boolean | undefined =>
values.find((value): value is boolean => typeof value === "boolean");
const pickFirstNumber = (...values: unknown[]): number | undefined =>
values.find((value): value is number => typeof value === "number");
const pickNullableString = (
...values: unknown[]
): string | null | undefined => {
for (const value of values) {
if (typeof value === "string") {
return value;
}
if (value === null) {
return null;
}
}
return undefined;
};
const getSettingsQueryFn = async (): Promise<Settings> => {
const settings = await SettingsService.getSettings();
const agentSettings = (settings.agent_settings ?? {}) as Record<
string,
SettingsValue
>;
return {
...settings,
llm_model:
pickFirstString(settings.llm_model, agentSettings["llm.model"]) ??
DEFAULT_SETTINGS.llm_model,
llm_base_url:
pickFirstString(settings.llm_base_url, agentSettings["llm.base_url"]) ??
DEFAULT_SETTINGS.llm_base_url,
agent:
pickFirstString(agentSettings.agent, settings.agent) ??
DEFAULT_SETTINGS.agent,
llm_api_key: settings.llm_api_key ?? null,
confirmation_mode:
pickFirstBoolean(
agentSettings["verification.confirmation_mode"],
settings.confirmation_mode,
) ?? DEFAULT_SETTINGS.confirmation_mode,
security_analyzer:
pickNullableString(
agentSettings["verification.security_analyzer"],
settings.security_analyzer,
) ?? DEFAULT_SETTINGS.security_analyzer,
enable_default_condenser:
pickFirstBoolean(
agentSettings["condenser.enabled"],
settings.enable_default_condenser,
) ?? DEFAULT_SETTINGS.enable_default_condenser,
condenser_max_size:
settings.condenser_max_size ?? DEFAULT_SETTINGS.condenser_max_size,
pickFirstNumber(
agentSettings["condenser.max_size"],
settings.condenser_max_size,
) ?? DEFAULT_SETTINGS.condenser_max_size,
mcp_config:
settings.mcp_config ??
(agentSettings.mcp_config as Settings["mcp_config"]) ??
DEFAULT_SETTINGS.mcp_config,
search_api_key: settings.search_api_key || "",
email: settings.email || "",
git_user_name: settings.git_user_name || DEFAULT_SETTINGS.git_user_name,
@@ -22,6 +85,9 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
disabled_skills:
settings.disabled_skills ?? DEFAULT_SETTINGS.disabled_skills,
v1_enabled: settings.v1_enabled ?? DEFAULT_SETTINGS.v1_enabled,
agent_settings_schema:
settings.agent_settings_schema ?? DEFAULT_SETTINGS.agent_settings_schema,
agent_settings: settings.agent_settings ?? DEFAULT_SETTINGS.agent_settings,
sandbox_grouping_strategy:
settings.sandbox_grouping_strategy ??
DEFAULT_SETTINGS.sandbox_grouping_strategy,
@@ -39,13 +105,10 @@ export const useSettings = () => {
const query = useQuery({
queryKey: ["settings", organizationId],
queryFn: getSettingsQueryFn,
// Only retry if the error is not a 404 because we
// would want to show the modal immediately if the
// settings are not found
retry: (_, error) => error.status !== 404,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 15,
enabled:
!isOnIntermediatePage &&
!!userIsAuthenticated &&
@@ -55,12 +118,7 @@ export const useSettings = () => {
},
});
// We want to return the defaults if the settings aren't found so the user can still see the
// options to make their initial save. We don't set the defaults in `initialData` above because
// that would prepopulate the data to the cache and mess with expectations. Read more:
// https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#using-initialdata-to-prepopulate-a-query
if (query.error?.status === 404) {
// Create a new object with only the properties we need, avoiding rest destructuring
return {
data: DEFAULT_SETTINGS,
error: query.error,

View File

@@ -104,6 +104,8 @@ export enum I18nKey {
HOME$RESOLVE_UNRESOLVED_COMMENTS = "HOME$RESOLVE_UNRESOLVED_COMMENTS",
HOME$LAUNCH = "HOME$LAUNCH",
SETTINGS$ADVANCED = "SETTINGS$ADVANCED",
SETTINGS$ALL = "SETTINGS$ALL",
SETTINGS$BASIC = "SETTINGS$BASIC",
SETTINGS$BASE_URL = "SETTINGS$BASE_URL",
SETTINGS$AGENT = "SETTINGS$AGENT",
SETTINGS$ENABLE_MEMORY_CONDENSATION = "SETTINGS$ENABLE_MEMORY_CONDENSATION",
@@ -119,6 +121,7 @@ export enum I18nKey {
ANALYTICS$CONFIRM_PREFERENCES = "ANALYTICS$CONFIRM_PREFERENCES",
SETTINGS$SAVING = "SETTINGS$SAVING",
SETTINGS$SAVE_CHANGES = "SETTINGS$SAVE_CHANGES",
SETTINGS$SDK_SCHEMA_UNAVAILABLE = "SETTINGS$SDK_SCHEMA_UNAVAILABLE",
SETTINGS$NAV_INTEGRATIONS = "SETTINGS$NAV_INTEGRATIONS",
SETTINGS$NAV_APPLICATION = "SETTINGS$NAV_APPLICATION",
SETTINGS$NAV_BILLING = "SETTINGS$NAV_BILLING",
@@ -137,7 +140,9 @@ export enum I18nKey {
SETTINGS$GITLAB_REINSTALL_WEBHOOK = "SETTINGS$GITLAB_REINSTALL_WEBHOOK",
SETTINGS$GITLAB_INSTALLING_WEBHOOK = "SETTINGS$GITLAB_INSTALLING_WEBHOOK",
SETTINGS$GITLAB = "SETTINGS$GITLAB",
SETTINGS$NAV_CONDENSER = "SETTINGS$NAV_CONDENSER",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
SETTINGS$NAV_VERIFICATION = "SETTINGS$NAV_VERIFICATION",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
GIT$GITLAB_API = "GIT$GITLAB_API",
GIT$PULL_REQUEST = "GIT$PULL_REQUEST",

View File

@@ -1767,6 +1767,40 @@
"uk": "Розширений",
"ca": "Avançat"
},
"SETTINGS$ALL": {
"en": "All",
"ja": "すべて",
"zh-CN": "全部",
"zh-TW": "全部",
"ko-KR": "전체",
"no": "Alle",
"ar": "الكل",
"de": "Alle",
"fr": "Tout",
"it": "Tutto",
"pt": "Tudo",
"es": "Todo",
"tr": "Tümü",
"uk": "Усі",
"ca": "Tots"
},
"SETTINGS$BASIC": {
"en": "Basic",
"ja": "Basic",
"zh-CN": "Basic",
"zh-TW": "Basic",
"ko-KR": "Basic",
"no": "Basic",
"it": "Basic",
"pt": "Basic",
"es": "Basic",
"ar": "Basic",
"fr": "Basic",
"tr": "Basic",
"de": "Basic",
"uk": "Basic",
"ca": "Bàsic"
},
"SETTINGS$BASE_URL": {
"en": "Base URL",
"ja": "ベースURL",
@@ -2022,6 +2056,23 @@
"uk": "Зберегти зміни",
"ca": "Desa els canvis"
},
"SETTINGS$SDK_SCHEMA_UNAVAILABLE": {
"en": "SDK settings schema unavailable.",
"ja": "SDK settings schema unavailable.",
"zh-CN": "SDK settings schema unavailable.",
"zh-TW": "SDK settings schema unavailable.",
"ko-KR": "SDK settings schema unavailable.",
"no": "SDK settings schema unavailable.",
"it": "SDK settings schema unavailable.",
"pt": "SDK settings schema unavailable.",
"es": "SDK settings schema unavailable.",
"ar": "SDK settings schema unavailable.",
"fr": "SDK settings schema unavailable.",
"tr": "SDK settings schema unavailable.",
"de": "SDK settings schema unavailable.",
"uk": "SDK settings schema unavailable.",
"ca": "L'esquema de configuració de l'SDK no està disponible."
},
"SETTINGS$NAV_INTEGRATIONS": {
"en": "Integrations",
"ja": "統合",
@@ -2328,6 +2379,23 @@
"uk": "GitLab",
"ca": "GitLab"
},
"SETTINGS$NAV_CONDENSER": {
"en": "Condenser",
"ja": "コンデンサー",
"zh-CN": "压缩器",
"zh-TW": "壓縮器",
"ko-KR": "압축기",
"no": "Kondensator",
"it": "Condensatore",
"pt": "Condensador",
"es": "Condensador",
"ar": "المكثف",
"fr": "Condenseur",
"tr": "Yoğunlaştırıcı",
"de": "Kondensator",
"uk": "Конденсатор",
"ca": "Condensador"
},
"SETTINGS$NAV_LLM": {
"en": "LLM",
"ja": "LLM",
@@ -2345,6 +2413,23 @@
"uk": "LLM",
"ca": "LLM"
},
"SETTINGS$NAV_VERIFICATION": {
"en": "Verification",
"ja": "検証",
"zh-CN": "验证",
"zh-TW": "驗證",
"ko-KR": "검증",
"no": "Verifisering",
"it": "Verifica",
"pt": "Verificação",
"es": "Verificación",
"ar": "التحقق",
"fr": "Vérification",
"tr": "Doğrulama",
"de": "Verifizierung",
"uk": "Верифікація",
"ca": "Verificació"
},
"GIT$MERGE_REQUEST": {
"en": "Merge Request",
"ja": "マージリクエスト",

View File

@@ -6,14 +6,20 @@ import {
UpdateOrganizationMemberParams,
} from "#/types/org";
const MOCK_MEMBER_AGENT_SETTINGS = {
"llm.model": "gpt-4",
"llm.base_url": "https://api.openai.com",
max_iterations: 20,
};
const MOCK_ME: Omit<OrganizationMember, "role" | "org_id"> = {
user_id: "99",
email: "me@acme.org",
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
};
@@ -28,23 +34,25 @@ export const createMockOrganization = (
contact_name: "Contact Name",
contact_email: "contact@example.com",
conversation_expiration: 86400,
agent: "default-agent",
default_max_iterations: 20,
security_analyzer: "standard",
confirmation_mode: false,
default_llm_model: "gpt-5-1",
default_llm_api_key_for_byor: "*********",
default_llm_base_url: "https://api.example-llm.com",
remote_runtime_resource_factor: 2,
enable_default_condenser: true,
billing_margin: 0.15,
enable_proactive_conversation_starters: true,
sandbox_base_container_image: "ghcr.io/example/sandbox-base:latest",
sandbox_runtime_container_image: "ghcr.io/example/sandbox-runtime:latest",
org_version: 0,
mcp_config: {
tools: [],
settings: {},
agent_settings: {
agent: "default-agent",
max_iterations: 20,
"verification.security_analyzer": "standard",
"verification.confirmation_mode": false,
"llm.model": "gpt-5-1",
"llm.base_url": "https://api.example-llm.com",
"condenser.enabled": true,
"condenser.max_size": 240,
mcp_config: {
tools: [],
settings: {},
},
},
search_api_key: null,
sandbox_api_key: null,
@@ -91,8 +99,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
],
@@ -105,8 +113,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
{
@@ -117,8 +125,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
{
@@ -129,8 +137,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
],
@@ -143,8 +151,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
{
@@ -155,8 +163,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
],
@@ -169,8 +177,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
{
@@ -181,8 +189,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
{
@@ -193,8 +201,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
{
@@ -205,8 +213,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "active",
},
{
@@ -217,8 +225,8 @@ const INITIAL_MOCK_MEMBERS: Record<string, OrganizationMember[]> = {
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "invited",
},
],
@@ -543,8 +551,8 @@ export const ORG_HANDLERS = [
llm_api_key: "**********",
max_iterations: 20,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "https://api.openai.com",
agent_settings: MOCK_MEMBER_AGENT_SETTINGS,
status: "invited" as const,
}));

View File

@@ -1,12 +1,14 @@
import { http, delay, HttpResponse } from "msw";
import { WebClientConfig } from "#/api/option-service/option.types";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Provider, Settings } from "#/types/settings";
import { Provider, Settings, SettingsValue } from "#/types/settings";
const DEFAULT_AGENT_SETTINGS = DEFAULT_SETTINGS.agent_settings ?? {};
const DEFAULT_MODEL =
typeof DEFAULT_AGENT_SETTINGS["llm.model"] === "string"
? DEFAULT_AGENT_SETTINGS["llm.model"]
: "openhands/claude-opus-4-5-20251101";
/**
* Creates a mock WebClientConfig with all required fields.
* Use this helper to create test config objects with sensible defaults.
*/
export const createMockWebClientConfig = (
overrides: Partial<WebClientConfig> = {},
): WebClientConfig => ({
@@ -34,27 +36,105 @@ export const createMockWebClientConfig = (
...overrides,
});
const MOCK_AGENT_SETTINGS_SCHEMA: NonNullable<
Settings["agent_settings_schema"]
> = {
model_name: "AgentSettings",
sections: [
{
key: "llm",
label: "LLM",
fields: [
{
key: "llm.model",
label: "Model",
section: "llm",
section_label: "LLM",
value_type: "string",
default: DEFAULT_MODEL,
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: true,
},
{
key: "llm.api_key",
label: "API Key",
section: "llm",
section_label: "LLM",
value_type: "string",
default: null,
choices: [],
depends_on: [],
prominence: "critical",
secret: true,
required: false,
},
{
key: "llm.base_url",
label: "Base URL",
section: "llm",
section_label: "LLM",
value_type: "string",
default: null,
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: false,
},
],
},
{
key: "critic",
label: "Critic",
fields: [
{
key: "critic.enabled",
label: "Enable critic",
section: "critic",
section_label: "Critic",
value_type: "boolean",
default: false,
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: true,
},
{
key: "critic.mode",
label: "Mode",
section: "critic",
section_label: "Critic",
value_type: "string",
default: "finish_and_message",
choices: [
{ label: "finish_and_message", value: "finish_and_message" },
{ label: "all_actions", value: "all_actions" },
],
depends_on: ["critic.enabled"],
prominence: "minor",
secret: false,
required: true,
},
],
},
],
};
export const MOCK_DEFAULT_USER_SETTINGS: Settings = {
llm_model: DEFAULT_SETTINGS.llm_model,
llm_base_url: DEFAULT_SETTINGS.llm_base_url,
llm_api_key: null,
llm_api_key_set: DEFAULT_SETTINGS.llm_api_key_set,
search_api_key_set: DEFAULT_SETTINGS.search_api_key_set,
agent: DEFAULT_SETTINGS.agent,
language: DEFAULT_SETTINGS.language,
confirmation_mode: DEFAULT_SETTINGS.confirmation_mode,
security_analyzer: DEFAULT_SETTINGS.security_analyzer,
remote_runtime_resource_factor:
DEFAULT_SETTINGS.remote_runtime_resource_factor,
...DEFAULT_SETTINGS,
provider_tokens_set: {},
enable_default_condenser: DEFAULT_SETTINGS.enable_default_condenser,
condenser_max_size: DEFAULT_SETTINGS.condenser_max_size,
enable_sound_notifications: DEFAULT_SETTINGS.enable_sound_notifications,
enable_proactive_conversation_starters:
DEFAULT_SETTINGS.enable_proactive_conversation_starters,
enable_solvability_analysis: DEFAULT_SETTINGS.enable_solvability_analysis,
user_consents_to_analytics: DEFAULT_SETTINGS.user_consents_to_analytics,
max_budget_per_task: DEFAULT_SETTINGS.max_budget_per_task,
agent_settings_schema: MOCK_AGENT_SETTINGS_SCHEMA,
agent_settings: {
...DEFAULT_AGENT_SETTINGS,
"critic.mode": "finish_and_message",
"critic.enabled": false,
"llm.api_key": null,
"llm.model": DEFAULT_MODEL,
},
};
const MOCK_USER_PREFERENCES: {
@@ -63,13 +143,10 @@ const MOCK_USER_PREFERENCES: {
settings: null,
};
// Reset mock
export const resetTestHandlersMockSettings = () => {
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
MOCK_USER_PREFERENCES.settings = structuredClone(MOCK_DEFAULT_USER_SETTINGS);
};
// --- Handlers for options/config/settings ---
export const SETTINGS_HANDLERS = [
http.get("/api/options/models", async () =>
HttpResponse.json([
@@ -115,8 +192,6 @@ export const SETTINGS_HANDLERS = [
},
providers_configured: [],
maintenance_start_time: null,
// Uncomment the following to test the maintenance banner
// maintenance_start_time: "2024-01-15T10:00:00-05:00", // EST timestamp
auth_url: null,
recaptcha_site_key: null,
faulty_models: [],
@@ -139,18 +214,43 @@ export const SETTINGS_HANDLERS = [
http.post("/api/settings", async ({ request }) => {
await delay();
const body = await request.json();
const body = (await request.json()) as Record<string, unknown> | null;
if (body) {
const current = MOCK_USER_PREFERENCES.settings || {
...MOCK_DEFAULT_USER_SETTINGS,
};
const current =
MOCK_USER_PREFERENCES.settings ||
structuredClone(MOCK_DEFAULT_USER_SETTINGS);
const agentFieldKeys = new Set(
current.agent_settings_schema?.sections.flatMap((section) =>
section.fields.map((field) => field.key),
) ?? [],
);
const agentSettings = {
...(current.agent_settings ?? {}),
} as Record<string, SettingsValue>;
MOCK_USER_PREFERENCES.settings = {
const nextSettings: Settings = {
...current,
...(body as Partial<Settings>),
};
for (const [key, value] of Object.entries(body)) {
if (agentFieldKeys.has(key)) {
agentSettings[key] =
value === null ||
typeof value === "boolean" ||
typeof value === "number" ||
typeof value === "string" ||
Array.isArray(value) ||
(typeof value === "object" && value !== null)
? (value as SettingsValue)
: null;
}
}
nextSettings.agent_settings = agentSettings;
MOCK_USER_PREFERENCES.settings = nextSettings;
return HttpResponse.json(null, { status: 200 });
}

View File

@@ -15,6 +15,8 @@ export default [
route("launch", "routes/launch.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/llm-settings.tsx"),
route("condenser", "routes/condenser-settings.tsx"),
route("verification", "routes/verification-settings.tsx"),
route("mcp", "routes/mcp-settings.tsx"),
route("skills", "routes/skills-settings.tsx"),
route("user", "routes/user-settings.tsx"),

View File

@@ -0,0 +1,15 @@
import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page";
import { createPermissionGuard } from "#/utils/org/permission-guard";
function CondenserSettingsScreen() {
return (
<SdkSectionPage
sectionKeys={["condenser"]}
testId="condenser-settings-screen"
/>
);
}
export const clientLoader = createPermissionGuard("view_llm_settings");
export default CondenserSettingsScreen;

View File

@@ -44,13 +44,14 @@ function MCPSettingsScreen() {
useState(false);
const [serverToDelete, setServerToDelete] = useState<string | null>(null);
const mcpConfig: MCPConfig = settings?.mcp_config || {
const mcpConfig: MCPConfig = (settings?.agent_settings?.mcp_config as
| MCPConfig
| undefined) || {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
};
// Convert servers to a unified format for display
const allServers: MCPServerConfig[] = [
...mcpConfig.sse_servers.map((server, index) => ({
id: `sse-${index}`,
@@ -118,7 +119,6 @@ function MCPSettingsScreen() {
const handleConfirmDelete = () => {
if (serverToDelete) {
handleDeleteServer(serverToDelete);
setServerToDelete(null);
}
};
@@ -127,67 +127,65 @@ function MCPSettingsScreen() {
setServerToDelete(null);
};
if (isLoading) {
if (isLoading || !settings) {
return null;
}
if (view === "add") {
return (
<div className="flex flex-col gap-5">
<div className="animate-pulse">
<div className="h-6 bg-gray-300 rounded w-1/4 mb-4" />
<div className="h-4 bg-gray-300 rounded w-1/2 mb-8" />
<div className="h-10 bg-gray-300 rounded w-32" />
</div>
</div>
<MCPServerForm
mode="add"
onSubmit={handleAddServer}
onCancel={() => setView("list")}
/>
);
}
if (view === "edit" && editingServer) {
return (
<MCPServerForm
mode="edit"
server={editingServer}
onSubmit={handleEditServer}
onCancel={() => {
setEditingServer(null);
setView("list");
}}
/>
);
}
return (
<div className="flex flex-col gap-5">
{view === "list" && (
<>
<BrandButton
testId="add-mcp-server-button"
type="button"
variant="primary"
onClick={() => setView("add")}
isDisabled={isLoading}
>
{t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
</BrandButton>
<div className="h-full max-w-[1000px] mx-auto flex flex-col px-6 gap-6 pb-8 pt-11">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-white mb-2">
{t(I18nKey.SETTINGS$MCP_TITLE)}
</h2>
<p className="text-sm text-[#A3A3A3]">
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
</p>
</div>
<BrandButton
type="button"
variant="primary"
onClick={() => setView("add")}
>
{t(I18nKey.SETTINGS$MCP_ADD_SERVER)}
</BrandButton>
</div>
<MCPServerList
servers={allServers}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
</>
)}
<MCPServerList
servers={allServers}
onEdit={handleEditClick}
onDelete={handleDeleteClick}
/>
{view === "add" && (
<MCPServerForm
mode="add"
existingServers={allServers}
onSubmit={handleAddServer}
onCancel={() => setView("list")}
/>
)}
{view === "edit" && editingServer && (
<MCPServerForm
mode="edit"
server={editingServer}
existingServers={allServers}
onSubmit={handleEditServer}
onCancel={() => {
setView("list");
setEditingServer(null);
}}
/>
)}
{confirmationModalIsVisible && (
{confirmationModalIsVisible && serverToDelete && (
<ConfirmationModal
text={t(I18nKey.SETTINGS$MCP_CONFIRM_DELETE)}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
onConfirm={handleConfirmDelete}
/>
)}
</div>

View File

@@ -0,0 +1,15 @@
import { SdkSectionPage } from "#/components/features/settings/sdk-settings/sdk-section-page";
import { createPermissionGuard } from "#/utils/org/permission-guard";
function VerificationSettingsScreen() {
return (
<SdkSectionPage
sectionKeys={["verification"]}
testId="verification-settings-screen"
/>
);
}
export const clientLoader = createPermissionGuard("view_llm_settings");
export default VerificationSettingsScreen;

View File

@@ -23,18 +23,32 @@ export const DEFAULT_SETTINGS: Settings = {
search_api_key: "",
is_new_user: true,
disabled_skills: [],
max_budget_per_task: null,
email: "",
email_verified: true, // Default to true to avoid restricting access unnecessarily
mcp_config: {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
},
max_budget_per_task: null,
email: "",
email_verified: true,
git_user_name: "openhands",
git_user_email: "openhands@all-hands.dev",
v1_enabled: false,
sandbox_grouping_strategy: "NO_GROUPING",
agent_settings: {
schema_version: 1,
agent: "CodeActAgent",
"llm.model": "openhands/claude-opus-4-5-20251101",
"verification.confirmation_mode": false,
"verification.security_analyzer": "llm",
"condenser.enabled": true,
"condenser.max_size": 240,
mcp_config: {
sse_servers: [],
stdio_servers: [],
shttp_servers: [],
},
},
};
/**

View File

@@ -6,24 +6,13 @@ export interface Organization {
contact_name: string;
contact_email: string;
conversation_expiration: number;
agent: string;
default_max_iterations: number;
security_analyzer: string;
confirmation_mode: boolean;
default_llm_model: string;
default_llm_api_key_for_byor: string;
default_llm_base_url: string;
remote_runtime_resource_factor: number;
enable_default_condenser: boolean;
billing_margin: number;
enable_proactive_conversation_starters: boolean;
sandbox_base_container_image: string;
sandbox_runtime_container_image: string;
org_version: number;
mcp_config: {
tools: unknown[];
settings: Record<string, unknown>;
};
agent_settings?: Record<string, unknown>;
search_api_key: string | null;
sandbox_api_key: string | null;
max_budget_per_task: number;
@@ -38,11 +27,12 @@ export interface OrganizationMember {
user_id: string;
email: string;
role: OrganizationUserRole;
llm_api_key: string;
max_iterations: number;
llm_model: string;
llm_api_key_for_byor: string | null;
llm_base_url: string;
llm_api_key: string;
agent_settings?: Record<string, unknown>;
status: "active" | "invited" | "inactive";
}

View File

@@ -50,6 +50,57 @@ export type MCPConfig = {
shttp_servers: (string | MCPSHTTPServer)[];
};
export type SettingsChoiceValue = boolean | number | string;
export type SettingsChoice = {
label: string;
value: SettingsChoiceValue;
};
export type SettingsValue =
| boolean
| number
| string
| null
| SettingsValue[]
| { [key: string]: SettingsValue };
export type SettingsValueType =
| "string"
| "integer"
| "number"
| "boolean"
| "array"
| "object";
export type SettingProminence = "critical" | "major" | "minor";
export type SettingsFieldSchema = {
key: string;
label: string;
description?: string | null;
section: string;
section_label: string;
value_type: SettingsValueType;
default?: SettingsValue;
choices: SettingsChoice[];
depends_on: string[];
prominence: SettingProminence;
secret: boolean;
required: boolean;
};
export type SettingsSectionSchema = {
key: string;
label: string;
fields: SettingsFieldSchema[];
};
export type SettingsSchema = {
model_name: string;
sections: SettingsSectionSchema[];
};
export type SkillInfo = {
name: string;
type: string;
@@ -70,7 +121,6 @@ export type Settings = {
remote_runtime_resource_factor: number | null;
provider_tokens_set: Partial<Record<Provider, string | null>>;
enable_default_condenser: boolean;
// Maximum number of events before the condenser runs
condenser_max_size: number | null;
enable_sound_notifications: boolean;
enable_proactive_conversation_starters: boolean;
@@ -86,5 +136,7 @@ export type Settings = {
git_user_name?: string;
git_user_email?: string;
v1_enabled?: boolean;
agent_settings_schema?: SettingsSchema | null;
agent_settings?: Record<string, SettingsValue> | null;
sandbox_grouping_strategy?: SandboxGroupingStrategy;
};

View File

@@ -1,5 +1,6 @@
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
import { getAgentSettingValue } from "#/utils/sdk-settings-schema";
/**
* Determines if any advanced-only settings are configured.
@@ -20,19 +21,18 @@ export const hasAdvancedSettingsSet = (
return false;
}
// Check for advanced-only settings that differ from defaults
const hasBaseUrl =
!!settings.llm_base_url && settings.llm_base_url.trim() !== "";
typeof getAgentSettingValue(settings as Settings, "llm.base_url") ===
"string" &&
getAgentSettingValue(settings as Settings, "llm.base_url") !== "";
const hasCustomAgent =
settings.agent !== undefined && settings.agent !== DEFAULT_SETTINGS.agent;
// Default is true, so only check if explicitly disabled
const hasDisabledCondenser = settings.enable_default_condenser === false;
// Check if condenser size differs from default (default is 240)
getAgentSettingValue(settings as Settings, "agent") !==
getAgentSettingValue(DEFAULT_SETTINGS, "agent");
const hasDisabledCondenser =
getAgentSettingValue(settings as Settings, "condenser.enabled") === false;
const hasCustomCondenserSize =
settings.condenser_max_size !== undefined &&
settings.condenser_max_size !== null &&
settings.condenser_max_size !== DEFAULT_SETTINGS.condenser_max_size;
// Check if search API key is set (non-empty string)
getAgentSettingValue(settings as Settings, "condenser.max_size") !==
getAgentSettingValue(DEFAULT_SETTINGS, "condenser.max_size");
const hasSearchApiKey =
settings.search_api_key !== undefined &&
settings.search_api_key !== null &&

View File

@@ -0,0 +1,216 @@
import { describe, expect, it } from "vitest";
import {
buildInitialSettingsFormValues,
buildSdkSettingsPayload,
getVisibleSettingsSections,
hasAdvancedSettingsOverrides,
inferInitialView,
SPECIALLY_RENDERED_KEYS,
} from "./sdk-settings-schema";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
const BASE_SETTINGS: Settings = {
...DEFAULT_SETTINGS,
agent_settings_schema: {
model_name: "AgentSettings",
sections: [
{
key: "llm",
label: "LLM",
fields: [
{
key: "llm.model",
label: "Model",
section: "llm",
section_label: "LLM",
value_type: "string",
default: "claude-sonnet-4-20250514",
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: true,
},
{
key: "llm.api_key",
label: "API Key",
section: "llm",
section_label: "LLM",
value_type: "string",
default: null,
choices: [],
depends_on: [],
prominence: "critical",
secret: true,
required: false,
},
{
key: "llm.base_url",
label: "Base URL",
section: "llm",
section_label: "LLM",
value_type: "string",
default: null,
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: false,
},
{
key: "llm.litellm_extra_body",
label: "LiteLLM Extra Body",
section: "llm",
section_label: "LLM",
value_type: "object",
default: {},
choices: [],
depends_on: [],
prominence: "minor",
secret: false,
required: false,
},
],
},
{
key: "critic",
label: "Critic",
fields: [
{
key: "critic.enabled",
label: "Enable critic",
section: "critic",
section_label: "Critic",
value_type: "boolean",
default: false,
choices: [],
depends_on: [],
prominence: "critical",
secret: false,
required: true,
},
{
key: "critic.mode",
label: "Mode",
section: "critic",
section_label: "Critic",
value_type: "string",
default: "finish_and_message",
choices: [
{ label: "finish_and_message", value: "finish_and_message" },
{ label: "all_actions", value: "all_actions" },
],
depends_on: ["critic.enabled"],
prominence: "minor",
secret: false,
required: true,
},
],
},
],
},
agent_settings: {
agent: "CodeActAgent",
"critic.mode": "finish_and_message",
"critic.enabled": false,
"llm.api_key": null,
"llm.model": "openai/gpt-4o",
"verification.confirmation_mode": false,
"condenser.enabled": true,
"condenser.max_size": 240,
},
};
describe("sdk settings schema helpers", () => {
it("builds initial form values from the current settings", () => {
expect(buildInitialSettingsFormValues(BASE_SETTINGS)).toEqual({
"critic.mode": "finish_and_message",
"critic.enabled": false,
"llm.api_key": "",
"llm.base_url": "",
"llm.litellm_extra_body": "{}",
"llm.model": "openai/gpt-4o",
});
});
it("detects advanced overrides from non-default values", () => {
expect(hasAdvancedSettingsOverrides(BASE_SETTINGS)).toBe(false);
expect(inferInitialView(BASE_SETTINGS)).toBe("basic");
const withMinorOverride: Settings = {
...BASE_SETTINGS,
agent_settings: {
...BASE_SETTINGS.agent_settings,
"critic.mode": "all_actions",
},
};
expect(hasAdvancedSettingsOverrides(withMinorOverride)).toBe(true);
expect(inferInitialView(withMinorOverride)).toBe("all");
});
it("filters fields by view tier and excludes specially-rendered keys", () => {
const values = buildInitialSettingsFormValues(BASE_SETTINGS);
const basicSections = getVisibleSettingsSections(
BASE_SETTINGS.agent_settings_schema!,
values,
"basic",
);
const allBasicFields = basicSections.flatMap((s) => s.fields);
for (const field of allBasicFields) {
expect(SPECIALLY_RENDERED_KEYS.has(field.key)).toBe(false);
expect(field.prominence).toBe("critical");
}
const allSections = getVisibleSettingsSections(
BASE_SETTINGS.agent_settings_schema!,
{ ...values, "critic.enabled": true },
"all",
);
const criticSection = allSections.find((s) => s.key === "critic");
expect(criticSection?.fields).toHaveLength(2);
});
it("passes through all fields when excludeKeys is empty", () => {
const values = buildInitialSettingsFormValues(BASE_SETTINGS);
const sections = getVisibleSettingsSections(
BASE_SETTINGS.agent_settings_schema!,
values,
"basic",
new Set(),
);
const allFieldKeys = sections.flatMap((s) => s.fields.map((f) => f.key));
expect(allFieldKeys).toContain("llm.model");
expect(allFieldKeys).toContain("llm.api_key");
});
it("builds a typed payload from dirty schema values", () => {
const payload = buildSdkSettingsPayload(
BASE_SETTINGS.agent_settings_schema!,
{
...buildInitialSettingsFormValues(BASE_SETTINGS),
"critic.enabled": true,
"llm.api_key": "new-key",
"llm.litellm_extra_body": JSON.stringify(
{ metadata: { tier: "enterprise" } },
null,
2,
),
},
{
"critic.enabled": true,
"llm.api_key": true,
"llm.litellm_extra_body": true,
"llm.model": false,
},
);
expect(payload).toEqual({
"critic.enabled": true,
"llm.api_key": "new-key",
"llm.litellm_extra_body": { metadata: { tier: "enterprise" } },
});
});
});

View File

@@ -0,0 +1,344 @@
import {
SettingProminence,
Settings,
SettingsFieldSchema,
SettingsSchema,
SettingsSectionSchema,
SettingsValue,
} from "#/types/settings";
export type SettingsFormValues = Record<string, string | boolean>;
export type SettingsDirtyState = Record<string, boolean>;
export type SdkSettingsPayload = Record<string, SettingsValue>;
export type SettingsView = "basic" | "advanced" | "all";
/** Fields that are rendered by purpose-built components instead of the
* generic `SchemaField` renderer. */
export const SPECIALLY_RENDERED_KEYS = new Set([
"llm.model",
"llm.api_key",
"llm.base_url",
]);
/** Prominence tiers visible at each view level. */
const VIEW_PROMINENCES: Record<SettingsView, Set<SettingProminence>> = {
basic: new Set<SettingProminence>(["critical"]),
advanced: new Set<SettingProminence>(["critical", "major"]),
all: new Set<SettingProminence>(["critical", "major", "minor"]),
};
function getSchemaFields(schema: SettingsSchema): SettingsFieldSchema[] {
return schema.sections.flatMap((section) => section.fields);
}
export function getAgentSettingValue(
settings: Settings,
key: string,
): SettingsValue {
return settings.agent_settings?.[key] ?? null;
}
function isChoiceField(field: SettingsFieldSchema): boolean {
return field.choices.length > 0;
}
function isCriticalField(field: SettingsFieldSchema): boolean {
return field.prominence === "critical";
}
function isMinorField(field: SettingsFieldSchema): boolean {
return field.prominence === "minor";
}
function normalizeFieldValue(
field: SettingsFieldSchema,
rawValue: unknown,
): string | boolean {
const resolvedValue = rawValue ?? field.default;
if (isChoiceField(field)) {
return resolvedValue === null || resolvedValue === undefined
? ""
: String(resolvedValue);
}
if (field.value_type === "boolean") {
return Boolean(resolvedValue ?? false);
}
if (resolvedValue === null || resolvedValue === undefined) {
return "";
}
if (field.value_type === "array" || field.value_type === "object") {
return JSON.stringify(resolvedValue, null, 2);
}
return String(resolvedValue);
}
function normalizeComparableValue(
field: SettingsFieldSchema,
rawValue: unknown,
): boolean | number | string | null {
if (rawValue === undefined) {
return null;
}
if (field.value_type === "boolean") {
if (typeof rawValue === "string") {
if (rawValue === "true") {
return true;
}
if (rawValue === "false") {
return false;
}
}
if (rawValue === null) {
return null;
}
return Boolean(rawValue);
}
if (field.value_type === "integer" || field.value_type === "number") {
if (rawValue === "" || rawValue === null) {
return null;
}
const parsedValue =
typeof rawValue === "number" ? rawValue : Number(String(rawValue));
return Number.isNaN(parsedValue) ? null : parsedValue;
}
if (field.value_type === "array" || field.value_type === "object") {
if (rawValue === null) {
return null;
}
if (typeof rawValue === "string") {
const trimmedValue = rawValue.trim();
if (!trimmedValue) {
return null;
}
try {
return JSON.stringify(JSON.parse(trimmedValue));
} catch {
return trimmedValue;
}
}
return JSON.stringify(rawValue);
}
if (rawValue === null) {
return null;
}
return String(rawValue);
}
export function buildInitialSettingsFormValues(
settings: Settings,
): SettingsFormValues {
const schema = settings.agent_settings_schema;
if (!schema) {
return {};
}
return Object.fromEntries(
getSchemaFields(schema).map((field) => [
field.key,
normalizeFieldValue(field, getAgentSettingValue(settings, field.key)),
]),
);
}
/** Determine which view tier to default to based on whether the user has
* overridden any non-critical settings. */
export function inferInitialView(
settings: Settings,
schemaOverride?: SettingsSchema | null,
): SettingsView {
const schema = schemaOverride ?? settings.agent_settings_schema;
if (!schema) {
return "basic";
}
let hasMinorOverride = false;
let hasMajorOverride = false;
for (const field of getSchemaFields(schema)) {
if (!isCriticalField(field)) {
const currentValue = getAgentSettingValue(settings, field.key);
const isDifferent =
normalizeComparableValue(
field,
currentValue ?? field.default ?? null,
) !== normalizeComparableValue(field, field.default ?? null);
if (isDifferent) {
if (isMinorField(field)) {
hasMinorOverride = true;
} else {
hasMajorOverride = true;
}
}
}
}
if (hasMinorOverride) return "all";
if (hasMajorOverride) return "advanced";
return "basic";
}
/** @deprecated Use {@link inferInitialView} instead. */
export function hasAdvancedSettingsOverrides(settings: Settings): boolean {
return inferInitialView(settings) !== "basic";
}
export function isSettingsFieldVisible(
field: SettingsFieldSchema,
values: SettingsFormValues,
): boolean {
return field.depends_on.every((dependency) => values[dependency] === true);
}
function parseBooleanFieldValue(rawValue: string | boolean): boolean | null {
if (typeof rawValue === "boolean") {
return rawValue;
}
const normalizedValue = rawValue.trim().toLowerCase();
if (!normalizedValue) {
return null;
}
if (normalizedValue === "true") {
return true;
}
if (normalizedValue === "false") {
return false;
}
throw new Error(`Expected a boolean value, received: ${rawValue}`);
}
function coerceFieldValue(
field: SettingsFieldSchema,
rawValue: string | boolean,
): SettingsValue {
if (field.value_type === "boolean") {
return parseBooleanFieldValue(rawValue);
}
if (field.value_type === "integer" || field.value_type === "number") {
const stringValue = String(rawValue).trim();
if (!stringValue) {
return null;
}
const parsedValue = Number(stringValue);
if (Number.isNaN(parsedValue)) {
throw new Error(`Expected a numeric value, received: ${stringValue}`);
}
if (field.value_type === "integer" && !Number.isInteger(parsedValue)) {
throw new Error(`Expected an integer value, received: ${stringValue}`);
}
return parsedValue;
}
if (field.value_type === "array" || field.value_type === "object") {
const stringValue = String(rawValue).trim();
if (!stringValue) {
return null;
}
let parsedValue: unknown;
try {
parsedValue = JSON.parse(stringValue);
} catch {
throw new Error(`Invalid JSON for ${field.label}`);
}
if (field.value_type === "array") {
if (!Array.isArray(parsedValue)) {
throw new Error(`${field.label} must be a JSON array`);
}
return parsedValue as SettingsValue[];
}
if (
parsedValue === null ||
Array.isArray(parsedValue) ||
typeof parsedValue !== "object"
) {
throw new Error(`${field.label} must be a JSON object`);
}
return parsedValue as { [key: string]: SettingsValue };
}
const stringValue = String(rawValue);
if (stringValue === "" && !field.secret) {
return null;
}
return stringValue;
}
export function buildSdkSettingsPayload(
schema: SettingsSchema,
values: SettingsFormValues,
dirty: SettingsDirtyState,
): SdkSettingsPayload {
const payload: SdkSettingsPayload = {};
for (const field of getSchemaFields(schema)) {
if (dirty[field.key]) {
payload[field.key] = coerceFieldValue(field, values[field.key]);
}
}
return payload;
}
function isFieldVisibleInView(
field: SettingsFieldSchema,
view: SettingsView,
): boolean {
return VIEW_PROMINENCES[view].has(field.prominence);
}
/** Return sections with fields filtered for the current view tier.
* Specially-rendered fields are excluded from the generic list. */
export function getVisibleSettingsSections(
schema: SettingsSchema,
values: SettingsFormValues,
view: SettingsView,
excludeKeys: Set<string> = SPECIALLY_RENDERED_KEYS,
): SettingsSectionSchema[] {
return schema.sections
.map((section) => ({
...section,
fields: section.fields.filter(
(field) =>
!excludeKeys.has(field.key) &&
isFieldVisibleInView(field, view) &&
isSettingsFieldVisible(field, values),
),
}))
.filter((section) => section.fields.length > 0);
}
/** Whether the schema has any fields beyond "critical" prominence. */
export function hasAdvancedSettings(schema: SettingsSchema | null): boolean {
if (!schema) return false;
return getSchemaFields(schema).some((f) => f.prominence !== "critical");
}
/** Whether the schema has any "minor" prominence fields. */
export function hasMinorSettings(schema: SettingsSchema | null): boolean {
if (!schema) return false;
return getSchemaFields(schema).some((f) => f.prominence === "minor");
}

View File

@@ -1,52 +1,17 @@
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
import { Settings } from "#/types/settings";
import { getProviderId } from "#/utils/map-provider";
import { WebClientFeatureFlags } from "#/api/option-service/option.types";
const extractBasicFormData = (formData: FormData) => {
const providerDisplay = formData.get("llm-provider-input")?.toString();
const provider = providerDisplay ? getProviderId(providerDisplay) : undefined;
const model = formData.get("llm-model-input")?.toString();
const LLM_MODEL = `${provider}/${model}`;
const LLM_API_KEY = formData.get("llm-api-key-input")?.toString();
const AGENT = formData.get("agent")?.toString();
const LANGUAGE = formData.get("language")?.toString();
return {
LLM_MODEL,
LLM_API_KEY,
AGENT,
LANGUAGE,
};
};
const extractAdvancedFormData = (formData: FormData) => {
const keys = Array.from(formData.keys());
const isUsingAdvancedOptions = keys.includes("use-advanced-options");
let CUSTOM_LLM_MODEL: string | undefined;
let LLM_BASE_URL: string | undefined;
let CONFIRMATION_MODE = false;
let SECURITY_ANALYZER: string | undefined;
let ENABLE_DEFAULT_CONDENSER = true;
if (isUsingAdvancedOptions) {
CUSTOM_LLM_MODEL = formData.get("custom-model")?.toString();
LLM_BASE_URL = formData.get("base-url")?.toString();
CONFIRMATION_MODE = keys.includes("confirmation-mode");
if (CONFIRMATION_MODE) {
// only set securityAnalyzer if confirmationMode is enabled
SECURITY_ANALYZER = formData.get("security-analyzer")?.toString();
}
ENABLE_DEFAULT_CONDENSER = keys.includes("enable-default-condenser");
}
return {
CUSTOM_LLM_MODEL,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
llmModel: provider && model ? `${provider}/${model}` : undefined,
llmApiKey: formData.get("llm-api-key-input")?.toString(),
agent: formData.get("agent")?.toString(),
language: formData.get("language")?.toString(),
};
};
@@ -62,34 +27,22 @@ export const parseMaxBudgetPerTask = (value: string): number | null => {
}
const parsedValue = parseFloat(value);
// Ensure the value is at least 1 dollar and is a finite number
return parsedValue && parsedValue >= 1 && Number.isFinite(parsedValue)
? parsedValue
: null;
};
export const extractSettings = (formData: FormData): Partial<Settings> => {
const { LLM_MODEL, LLM_API_KEY, AGENT, LANGUAGE } =
export const extractSettings = (
formData: FormData,
): Partial<Settings> & Record<string, unknown> => {
const { llmModel, llmApiKey, agent, language } =
extractBasicFormData(formData);
const {
CUSTOM_LLM_MODEL,
LLM_BASE_URL,
CONFIRMATION_MODE,
SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER,
} = extractAdvancedFormData(formData);
return {
llm_model: CUSTOM_LLM_MODEL || LLM_MODEL,
llm_api_key_set: !!LLM_API_KEY,
agent: AGENT,
language: LANGUAGE,
llm_base_url: LLM_BASE_URL,
confirmation_mode: CONFIRMATION_MODE,
security_analyzer: SECURITY_ANALYZER,
enable_default_condenser: ENABLE_DEFAULT_CONDENSER,
llm_api_key: LLM_API_KEY,
...(llmModel ? { "llm.model": llmModel } : {}),
...(llmApiKey !== undefined ? { "llm.api_key": llmApiKey } : {}),
...(agent ? { agent } : {}),
...(language ? { language } : {}),
};
};

View File

@@ -91,6 +91,7 @@ from openhands.sdk.hooks import HookConfig
from openhands.sdk.llm import LLM
from openhands.sdk.plugin import PluginSource
from openhands.sdk.secret import LookupSecret, SecretValue, StaticSecret
from openhands.sdk.settings import AgentSettings
from openhands.sdk.utils.paging import page_iterator
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
@@ -374,11 +375,12 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Set security analyzer from settings
user = await self.user_context.get_user_info()
verification_settings = user.agent_settings.verification
await self._set_security_analyzer_from_settings(
agent_server_url,
sandbox.session_api_key,
info.id,
user.security_analyzer,
verification_settings.security_analyzer,
self.httpx_client,
)
@@ -878,6 +880,19 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
return secrets
def _get_agent_settings(
self, user: UserInfo, llm_model: str | None
) -> AgentSettings:
"""Resolve SDK ``AgentSettings`` for this request."""
settings = user.agent_settings
if llm_model is not None:
settings = settings.model_copy(
update={
'llm': settings.llm.model_copy(update={'model': llm_model}),
}
)
return settings
def _configure_llm(self, user: UserInfo, llm_model: str | None) -> LLM:
"""Configure LLM settings.
@@ -888,16 +903,14 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
Returns:
Configured LLM instance
"""
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
agent_settings = self._get_agent_settings(user, llm_model)
llm_settings = agent_settings.llm.model_copy(deep=True)
return LLM(
model=model,
base_url=base_url,
api_key=user.llm_api_key,
usage_id='agent',
return LLM.model_validate(
{
**llm_settings.model_dump(mode='python'),
'usage_id': 'agent',
}
)
async def _get_tavily_api_key(self, user: UserInfo) -> str | None:
@@ -1037,30 +1050,73 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
def _merge_custom_mcp_config(
self, mcp_servers: dict[str, Any], user: UserInfo
) -> None:
"""Merge custom MCP configuration from user settings.
Args:
mcp_servers: Dictionary to add servers to
user: User information containing custom MCP config
"""
if not user.mcp_config:
"""Merge custom MCP configuration from canonical SDK agent settings."""
sdk_mcp_config = user.agent_settings.mcp_config
if not sdk_mcp_config:
return
try:
sse_count = len(user.mcp_config.sse_servers)
shttp_count = len(user.mcp_config.shttp_servers)
stdio_count = len(user.mcp_config.stdio_servers)
raw_mcp_config = (
sdk_mcp_config.model_dump(exclude_none=True, exclude_defaults=True)
if hasattr(sdk_mcp_config, 'model_dump')
else sdk_mcp_config
)
if not isinstance(raw_mcp_config, dict):
raise TypeError('mcp_config must serialize to a dict')
raw_mcp_servers = raw_mcp_config.get('mcpServers', {})
if not isinstance(raw_mcp_servers, dict):
raise TypeError('mcpServers must be a dict')
sse_count = 0
shttp_count = 0
stdio_count = 0
for server_name, server_config in raw_mcp_servers.items():
if not isinstance(server_config, dict):
raise TypeError(f'MCP server {server_name} must be a dict')
url = server_config.get('url')
if url:
transport = server_config.get('transport')
merged_server: dict[str, Any] = {
'url': url,
'transport': 'sse' if transport == 'sse' else 'streamable-http',
}
headers = dict(server_config.get('headers') or {})
auth = server_config.get('auth')
if (
isinstance(auth, str)
and auth != 'oauth'
and 'Authorization' not in headers
):
headers['Authorization'] = f'Bearer {auth}'
if headers:
merged_server['headers'] = headers
if (
merged_server['transport'] == 'streamable-http'
and server_config.get('timeout') is not None
):
merged_server['timeout'] = server_config['timeout']
mcp_servers[server_name] = merged_server
if merged_server['transport'] == 'sse':
sse_count += 1
else:
shttp_count += 1
continue
merged_server = {'command': server_config['command']}
if server_config.get('args'):
merged_server['args'] = server_config['args']
if server_config.get('env'):
merged_server['env'] = server_config['env']
mcp_servers[server_name] = merged_server
stdio_count += 1
_logger.info(
f'Loading custom MCP config from user settings: '
f'{sse_count} SSE, {shttp_count} SHTTP, {stdio_count} STDIO servers'
)
# Add each type of custom server
self._add_custom_sse_servers(mcp_servers, user.mcp_config.sse_servers)
self._add_custom_shttp_servers(mcp_servers, user.mcp_config.shttp_servers)
self._add_custom_stdio_servers(mcp_servers, user.mcp_config.stdio_servers)
_logger.info(
f'Successfully merged custom MCP config: added {sse_count} SSE, '
f'{shttp_count} SHTTP, and {stdio_count} STDIO servers'
@@ -1071,7 +1127,6 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
f'Error loading custom MCP config from user settings: {e}',
exc_info=True,
)
# Continue with system config only, don't fail conversation startup
_logger.warning(
'Continuing with system-generated MCP config only due to custom config error'
)
@@ -1106,78 +1161,67 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
return llm, mcp_config
def _create_agent_with_context(
def _create_agent(
self,
llm: LLM,
agent_type: AgentType,
system_message_suffix: str | None,
mcp_config: dict,
condenser_max_size: int | None,
secrets: dict[str, SecretValue] | None = None,
git_provider: ProviderType | None = None,
working_dir: str | None = None,
agent_settings: AgentSettings | None = None,
) -> Agent:
"""Create an agent with appropriate tools and context based on agent type.
"""Create an agent from fully-resolved settings.
Args:
llm: Configured LLM instance
agent_type: Type of agent to create (PLAN or DEFAULT)
system_message_suffix: Optional suffix for system messages
mcp_config: MCP configuration dictionary
condenser_max_size: condenser_max_size setting
secrets: Optional dictionary of secrets for authentication
git_provider: Optional git provider type for computing plan path
working_dir: Optional working directory for computing plan path
Returns:
Configured Agent instance with context
Supplies runtime-determined fields (tools, agent context, MCP
config, system-prompt overrides) and delegates to
``AgentSettings.create_agent()``.
"""
# Create condenser with user's settings
condenser = self._create_condenser(llm, agent_type, condenser_max_size)
# Create agent based on type
if agent_type == AgentType.PLAN:
# Compute plan path if working_dir is provided
plan_path = None
if working_dir:
plan_path = self._compute_plan_path(working_dir, git_provider)
agent = Agent(
llm=llm,
tools=get_planning_tools(plan_path=plan_path),
system_prompt_filename='system_prompt_planning.j2',
system_prompt_kwargs={'plan_structure': format_plan_structure()},
condenser=condenser,
security_analyzer=None,
mcp_config=mcp_config,
)
else:
agent = Agent(
llm=llm,
tools=get_default_tools(enable_browser=True),
system_prompt_kwargs={'cli_mode': False},
condenser=condenser,
mcp_config=mcp_config,
)
# Prepare system message suffix based on agent type
effective_system_message_suffix = system_message_suffix
if agent_type == AgentType.PLAN:
# Prepend planning-specific instruction to prevent "Ready to proceed?" behavior
if system_message_suffix:
effective_system_message_suffix = (
f'{PLANNING_AGENT_INSTRUCTION}\n\n{system_message_suffix}'
)
else:
effective_system_message_suffix = PLANNING_AGENT_INSTRUCTION
# Add agent context
agent_context = AgentContext(
system_message_suffix=effective_system_message_suffix, secrets=secrets
# Tools
plan_path = None
if agent_type == AgentType.PLAN and working_dir:
plan_path = self._compute_plan_path(working_dir, git_provider)
tools = (
get_planning_tools(plan_path=plan_path)
if agent_type == AgentType.PLAN
else get_default_tools(enable_browser=True)
)
agent = agent.model_copy(update={'agent_context': agent_context})
return agent
# System message suffix
effective_suffix = system_message_suffix
if agent_type == AgentType.PLAN:
effective_suffix = (
f'{PLANNING_AGENT_INSTRUCTION}\n\n{system_message_suffix}'
if system_message_suffix
else PLANNING_AGENT_INSTRUCTION
)
# Build agent from settings
assert agent_settings is not None
agent = agent_settings.model_copy(
update={
'llm': llm,
'tools': tools,
'mcp_config': mcp_config,
'agent_context': AgentContext(
system_message_suffix=effective_suffix,
secrets=secrets,
),
}
).create_agent()
# Agent-type-specific prompt overrides
runtime_overrides: dict[str, Any] = {}
if agent_type == AgentType.PLAN:
runtime_overrides['system_prompt_filename'] = 'system_prompt_planning.j2'
runtime_overrides['system_prompt_kwargs'] = {
'plan_structure': format_plan_structure()
}
else:
runtime_overrides['system_prompt_kwargs'] = {'cli_mode': False}
return agent.model_copy(update=runtime_overrides)
def _update_agent_with_llm_metadata(
self,
@@ -1441,13 +1485,16 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
for p in plugins
]
verification_settings = user.agent_settings.verification
# Create and return the final request
return StartConversationRequest(
conversation_id=conversation_id,
agent=agent,
workspace=workspace,
confirmation_policy=self._select_confirmation_policy(
bool(user.confirmation_mode), user.security_analyzer
verification_settings.confirmation_mode,
verification_settings.security_analyzer,
),
initial_message=final_initial_message,
secrets=secrets,
@@ -1491,17 +1538,18 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
# Configure LLM and MCP
llm, mcp_config = await self._configure_llm_and_mcp(user, llm_model)
agent_settings = self._get_agent_settings(user, llm_model)
# Create agent with context
agent = self._create_agent_with_context(
# Create agent from settings
agent = self._create_agent(
llm,
agent_type,
system_message_suffix,
mcp_config,
user.condenser_max_size,
secrets=secrets,
git_provider=git_provider,
working_dir=project_dir,
agent_settings=agent_settings,
)
# Finalize and return the conversation request

View File

@@ -564,7 +564,7 @@ class DockerNestedConversationManager(ConversationManager):
user_id=user_id,
)
llm_registry.retry_listner = session._notify_on_llm_retry
agent_cls = settings.agent or config.default_agent
agent_cls = settings.agent_settings.agent or config.default_agent
agent_config = config.get_agent_config(agent_cls)
agent = Agent.get_cls(agent_cls)(agent_config, llm_registry)

View File

@@ -725,14 +725,15 @@ async def get_prompt(
# placeholder for error handling
raise ValueError('Settings not found')
settings_base_url = settings.llm_base_url
agent_settings = settings.agent_settings
settings_base_url = agent_settings.llm.base_url
effective_base_url = get_effective_llm_base_url(
settings.llm_model,
agent_settings.llm.model,
settings_base_url,
)
llm_config = LLMConfig(
model=settings.llm_model or '',
api_key=settings.llm_api_key,
model=agent_settings.llm.model,
api_key=agent_settings.llm.api_key,
base_url=effective_base_url,
)

View File

@@ -128,22 +128,23 @@ async def store_provider_tokens(
if not user_secrets:
user_secrets = Secrets()
if provider_info.provider_tokens:
updated_provider_tokens = dict(provider_info.provider_tokens or {})
if updated_provider_tokens:
existing_providers = [provider for provider in user_secrets.provider_tokens]
# Merge incoming settings store with the existing one
for provider, token_value in list(provider_info.provider_tokens.items()):
for provider, token_value in list(updated_provider_tokens.items()):
if provider in existing_providers and not token_value.token:
existing_token = user_secrets.provider_tokens.get(provider)
if existing_token and existing_token.token:
provider_info.provider_tokens[provider] = existing_token
updated_provider_tokens[provider] = existing_token
provider_info.provider_tokens[provider] = provider_info.provider_tokens[
updated_provider_tokens[provider] = updated_provider_tokens[
provider
].model_copy(update={'host': token_value.host})
updated_secrets = user_secrets.model_copy(
update={'provider_tokens': provider_info.provider_tokens}
update={'provider_tokens': updated_provider_tokens}
)
await secrets_store.store(updated_secrets)

View File

@@ -6,7 +6,7 @@
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
# Tag: Legacy-V0
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
import os
from typing import Any
from fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
@@ -16,6 +16,7 @@ from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
ProviderType,
)
from openhands.sdk.settings import AgentSettings
from openhands.server.dependencies import get_dependencies
from openhands.server.routes.secrets import invalidate_legacy_secrets_store
from openhands.server.settings import (
@@ -31,12 +32,91 @@ from openhands.server.user_auth import (
from openhands.storage.data_models.settings import Settings
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
from openhands.utils.llm import get_provider_api_base, is_openhands_model
LITE_LLM_API_URL = os.environ.get(
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
def _get_agent_settings_schema() -> dict[str, Any]:
"""Return the SDK agent settings schema for the legacy V0 settings API."""
return AgentSettings.export_schema().model_dump(mode='json')
def _get_schema_field_keys(schema: dict[str, Any] | None) -> set[str]:
if not schema:
return set()
return {
field['key']
for section in schema.get('sections', [])
for field in section.get('fields', [])
}
def _get_schema_secret_field_keys(schema: dict[str, Any] | None) -> set[str]:
if not schema:
return set()
return {
field['key']
for section in schema.get('sections', [])
for field in section.get('fields', [])
if field.get('secret')
}
_SECRET_REDACTED = '<hidden>'
def _extract_agent_settings(
settings: Settings, schema: dict[str, Any] | None
) -> dict[str, Any]:
"""Build the agent_settings dict for the GET response.
Secret fields with a value are redacted to ``"<hidden>"``;
unset secrets become ``None``.
"""
values = settings.agent_settings_values()
for field_key in _get_schema_secret_field_keys(schema):
raw = values.get(field_key)
values[field_key] = _SECRET_REDACTED if raw else None
return values
_SETTINGS_FROZEN_FIELDS = frozenset(
name for name, field_info in Settings.model_fields.items() if field_info.frozen
)
def _apply_settings_payload(
payload: dict[str, Any],
existing_settings: Settings | None,
agent_schema: dict[str, Any] | None,
) -> Settings:
"""Apply an incoming settings payload.
SDK dotted keys (e.g. ``llm.model``) go into ``agent_settings``.
Other keys (e.g. ``language``, ``git_user_name``) are set directly
on the ``Settings`` model.
"""
settings = existing_settings.model_copy() if existing_settings else Settings()
schema_field_keys = _get_schema_field_keys(agent_schema)
secret_field_keys = _get_schema_secret_field_keys(agent_schema)
agent_settings = dict(settings.raw_agent_settings)
for key, value in payload.items():
if key in schema_field_keys:
if key in secret_field_keys:
if value is not None and value != '' and value != _SECRET_REDACTED:
agent_settings[key] = value
elif value is None:
agent_settings.pop(key, None)
else:
agent_settings[key] = value
elif key in Settings.model_fields and key not in _SETTINGS_FROZEN_FIELDS:
setattr(settings, key, value)
object.__setattr__(settings, 'raw_agent_settings', agent_settings)
settings.normalize_agent_settings()
return settings
app = APIRouter(prefix='/api', dependencies=get_dependencies())
@@ -77,32 +157,25 @@ async def load_settings(
if provider_token.token or provider_token.user_id:
provider_tokens_set[provider_type] = provider_token.host
agent_settings_schema = _get_agent_settings_schema()
agent_vals = _extract_agent_settings(settings, agent_settings_schema)
settings_with_token_data = GETSettingsModel(
**settings.model_dump(exclude={'secrets_store'}),
llm_api_key_set=settings.llm_api_key is not None
and bool(settings.llm_api_key),
**settings.model_dump(exclude={'secrets_store', 'raw_agent_settings'}),
llm_api_key_set=settings.llm_api_key_is_set,
search_api_key_set=settings.search_api_key is not None
and bool(settings.search_api_key),
provider_tokens_set=provider_tokens_set,
agent_settings_schema=agent_settings_schema,
agent_settings=agent_vals,
)
# If the base url matches the default for the provider, we don't send it
# So that the frontend can display basic mode
if is_openhands_model(settings.llm_model):
if settings.llm_base_url == LITE_LLM_API_URL:
settings_with_token_data.llm_base_url = None
elif settings.llm_model and settings.llm_base_url == get_provider_api_base(
settings.llm_model
):
settings_with_token_data.llm_base_url = None
settings_with_token_data.llm_api_key = None
# Redact secrets from the response.
settings_with_token_data.search_api_key = None
settings_with_token_data.sandbox_api_key = None
return settings_with_token_data
except Exception as e:
logger.warning(f'Invalid token: {e}')
# Get user_id from settings if available
user_id = getattr(settings, 'user_id', 'unknown') if settings else 'unknown'
logger.info(
f'Returning 401 Unauthorized - Invalid token for user_id: {user_id}'
@@ -113,47 +186,6 @@ async def load_settings(
)
async def store_llm_settings(
settings: Settings, existing_settings: Settings
) -> Settings:
# Convert to Settings model and merge with existing settings
if existing_settings:
# Keep existing LLM settings if not provided
if settings.llm_api_key is None:
settings.llm_api_key = existing_settings.llm_api_key
if settings.llm_model is None:
settings.llm_model = existing_settings.llm_model
if settings.llm_base_url is None:
# Not provided at all (e.g. MCP config save) - preserve existing or auto-detect
if existing_settings.llm_base_url:
settings.llm_base_url = existing_settings.llm_base_url
elif is_openhands_model(settings.llm_model):
# OpenHands models use the LiteLLM proxy
settings.llm_base_url = LITE_LLM_API_URL
elif settings.llm_model:
# For non-openhands models, try to get URL from litellm
try:
api_base = get_provider_api_base(settings.llm_model)
if api_base:
settings.llm_base_url = api_base
else:
logger.debug(
f'No api_base found in litellm for model: {settings.llm_model}'
)
except Exception as e:
logger.error(
f'Failed to get api_base from litellm for model {settings.llm_model}: {e}'
)
elif settings.llm_base_url == '':
# Explicitly cleared by the user (basic view save or advanced view clear)
settings.llm_base_url = None
# Keep search API key if missing or empty
if not settings.search_api_key:
settings.search_api_key = existing_settings.search_api_key
return settings
# NOTE: We use response_model=None for endpoints that return JSONResponse directly.
# This is because FastAPI's response_model expects a Pydantic model, but we're returning
# a response object directly. We document the possible responses using the 'responses'
@@ -167,18 +199,19 @@ async def store_llm_settings(
},
)
async def store_settings(
settings: Settings,
payload: dict[str, Any],
settings_store: SettingsStore = Depends(get_user_settings_store),
) -> JSONResponse:
# Check provider tokens are valid
try:
existing_settings = await settings_store.load()
agent_settings_schema = _get_agent_settings_schema()
settings = _apply_settings_payload(
payload, existing_settings, agent_settings_schema
)
# Convert to Settings model and merge with existing settings
if existing_settings:
settings = await store_llm_settings(settings, existing_settings)
# Keep existing analytics consent if not provided
if not settings.search_api_key:
settings.search_api_key = existing_settings.search_api_key
if settings.user_consents_to_analytics is None:
settings.user_consents_to_analytics = (
existing_settings.user_consents_to_analytics
@@ -203,14 +236,11 @@ async def store_settings(
config.git_user_email = settings.git_user_email
git_config_updated = True
# Note: Git configuration will be applied when new sessions are initialized
# Existing sessions will continue with their current git configuration
if git_config_updated:
logger.info(
f'Updated global git configuration: name={config.git_user_name}, email={config.git_user_email}'
)
settings = convert_to_settings(settings)
await settings_store.store(settings)
return JSONResponse(
status_code=status.HTTP_200_OK,
@@ -222,22 +252,3 @@ async def store_settings(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={'error': 'Something went wrong storing settings'},
)
def convert_to_settings(settings_with_token_data: Settings) -> Settings:
settings_data = settings_with_token_data.model_dump()
# Filter out additional fields from `SettingsWithTokenData`
filtered_settings_data = {
key: value
for key, value in settings_data.items()
if key in Settings.model_fields # Ensures only `Settings` fields are included
}
# Convert the API keys to `SecretStr` instances
filtered_settings_data['llm_api_key'] = settings_with_token_data.llm_api_key
filtered_settings_data['search_api_key'] = settings_with_token_data.search_api_key
# Create a new Settings instance
settings = Settings(**filtered_settings_data)
return settings

View File

@@ -104,22 +104,19 @@ async def start_conversation(
session_init_args: dict[str, Any] = {}
if settings:
session_init_args = {**settings.__dict__, **session_init_args}
# We could use litellm.check_valid_key for a more accurate check,
# but that would run a tiny inference.
model_name = settings.llm_model or ''
session_init_args = settings.model_dump()
agent_settings = settings.agent_settings
model_name = agent_settings.llm.model
llm_api_key = agent_settings.llm.api_key
is_bedrock_model = model_name.startswith('bedrock/')
is_lemonade_model = model_name.startswith('lemonade/')
if (
not is_bedrock_model
and not is_lemonade_model
and (
not settings.llm_api_key
or settings.llm_api_key.get_secret_value().isspace()
)
and (not llm_api_key or llm_api_key.get_secret_value().isspace())
):
logger.warning(f'Missing api key for model {settings.llm_model}')
logger.warning(f'Missing api key for model {model_name}')
raise LLMAuthenticationError(
'Error authenticating with the LLM provider. Please check your API key'
)
@@ -137,7 +134,9 @@ async def start_conversation(
session_init_args['git_provider'] = conversation_metadata.git_provider
session_init_args['conversation_instructions'] = conversation_instructions
if mcp_config:
session_init_args['mcp_config'] = mcp_config
agent_settings_payload = dict(session_init_args.get('agent_settings') or {})
agent_settings_payload['mcp_config'] = mcp_config.model_dump(mode='python')
session_init_args['agent_settings'] = agent_settings_payload
conversation_init_data = ConversationInitData(**session_init_args)
@@ -244,8 +243,7 @@ async def setup_init_conversation_settings(
'Settings not found', {'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND'}
)
session_init_args: dict = {}
session_init_args = {**settings.__dict__, **session_init_args}
session_init_args: dict = settings.model_dump()
# Use provided tokens if available (for SAAS resume), otherwise create scaffold
if provider_tokens:

View File

@@ -140,17 +140,11 @@ class WebSession:
AgentStateChangedObservation('', AgentState.LOADING),
EventSource.ENVIRONMENT,
)
agent_cls = settings.agent or self.config.default_agent
self.config.security.confirmation_mode = (
self.config.security.confirmation_mode
if settings.confirmation_mode is None
else settings.confirmation_mode
)
self.config.security.security_analyzer = (
self.config.security.security_analyzer
if settings.security_analyzer is None
else settings.security_analyzer
)
agent_settings = settings.agent_settings
agent_cls = agent_settings.agent or self.config.default_agent
verification_settings = agent_settings.verification
self.config.security.confirmation_mode = verification_settings.confirmation_mode
self.config.security.security_analyzer = verification_settings.security_analyzer
self.config.sandbox.base_container_image = (
settings.sandbox_base_container_image
or self.config.sandbox.base_container_image
@@ -169,7 +163,9 @@ class WebSession:
git_user_email = getattr(settings, 'git_user_email', None)
if git_user_email is not None:
self.config.git_user_email = git_user_email
max_iterations = settings.max_iterations or self.config.max_iterations
max_iterations = (
settings.get_agent_setting('max_iterations') or self.config.max_iterations
)
# Prioritize settings over config for max_budget_per_task
max_budget_per_task = (
@@ -187,12 +183,10 @@ class WebSession:
f'MCP configuration before setup - self.config.mcp_config: {self.config.mcp}'
)
# Check if settings has custom mcp_config
mcp_config = getattr(settings, 'mcp_config', None)
if mcp_config is not None:
# Use the provided MCP SHTTP servers instead of default setup
self.config.mcp = self.config.mcp.merge(mcp_config)
self.logger.debug(f'Merged custom MCP Config: {mcp_config}')
custom_mcp_config = settings.to_legacy_mcp_config()
if custom_mcp_config:
self.config.mcp = self.config.mcp.merge(custom_mcp_config)
self.logger.debug(f'Merged custom MCP Config: {custom_mcp_config}')
# Add OpenHands' MCP server by default
(
@@ -218,7 +212,7 @@ class WebSession:
agent_config.runtime = self.config.runtime
agent_name = agent_cls if agent_cls is not None else 'agent'
llm_config = self.config.get_llm_config_from_agent(agent_name)
if settings.enable_default_condenser:
if agent_settings.condenser.enabled:
# Default condenser chains three condensers together:
# 1. a conversation window condenser that handles explicit
# condensation requests,
@@ -228,7 +222,6 @@ class WebSession:
# The order matters: with the browser output first, the summarizer
# will only see the most recent browser output, which should keep
# the summarization cost down.
max_events_for_condenser = settings.condenser_max_size or 240
default_condenser_config = CondenserPipelineConfig(
condensers=[
ConversationWindowCondenserConfig(),
@@ -236,7 +229,7 @@ class WebSession:
LLMSummarizingCondenserConfig(
llm_config=llm_config,
keep_first=4,
max_size=max_events_for_condenser,
max_size=agent_settings.condenser.max_size,
),
]
)
@@ -246,7 +239,7 @@ class WebSession:
f' browser_output_masking(attention_window=2), '
f' llm(model="{llm_config.model}", '
f' base_url="{llm_config.base_url}", '
f' keep_first=4, max_size={max_events_for_condenser})'
f' keep_first=4, max_size={agent_settings.condenser.max_size})'
)
agent_config.condenser = default_condenser_config
agent = Agent.get_cls(agent_cls)(agent_config, self.llm_registry)

View File

@@ -8,6 +8,8 @@
# This module belongs to the old V0 web server. The V1 application server lives under openhands/app_server/.
from __future__ import annotations
from typing import Any
from pydantic import (
BaseModel,
ConfigDict,
@@ -15,50 +17,51 @@ from pydantic import (
)
from openhands.core.config.mcp_config import MCPConfig
from openhands.integrations.provider import CustomSecret, ProviderToken
from openhands.integrations.provider import CUSTOM_SECRETS_TYPE, PROVIDER_TOKEN_TYPE
from openhands.integrations.service_types import ProviderType
from openhands.storage.data_models.settings import Settings
class POSTProviderModel(BaseModel):
"""Settings for POST requests"""
"""Settings for POST requests."""
mcp_config: MCPConfig | None = None
provider_tokens: dict[ProviderType, ProviderToken] = {}
provider_tokens: PROVIDER_TOKEN_TYPE = {}
class POSTCustomSecrets(BaseModel):
"""Adding new custom secret"""
"""Add a new custom secret."""
custom_secrets: dict[str, CustomSecret] = {}
custom_secrets: CUSTOM_SECRETS_TYPE = {}
class GETSettingsModel(Settings):
"""Settings with additional token data for the frontend"""
"""Settings with additional token data for the frontend."""
provider_tokens_set: dict[ProviderType, str | None] | None = (
None # provider + base_domain key-value pair
)
llm_api_key_set: bool
search_api_key_set: bool = False
agent_settings_schema: dict[str, Any] | None = None
model_config = ConfigDict(use_enum_values=True)
class CustomSecretWithoutValueModel(BaseModel):
"""Custom secret model without value"""
"""Custom secret model without a value."""
name: str
description: str | None = None
class CustomSecretModel(CustomSecretWithoutValueModel):
"""Custom secret model with value"""
"""Custom secret model with a value."""
value: SecretStr
class GETCustomSecrets(BaseModel):
"""Custom secrets names"""
"""Custom secret names."""
custom_secrets: list[CustomSecretWithoutValueModel] | None = None

View File

@@ -1,152 +1,503 @@
from __future__ import annotations
from enum import Enum
from typing import Annotated
from functools import lru_cache
from typing import Annotated, Any, get_args, get_origin
from fastmcp.mcp_config import MCPConfig as SDKMCPConfig
from pydantic import (
BaseModel,
ConfigDict,
Field,
PrivateAttr,
SecretStr,
SerializationInfo,
field_serializer,
field_validator,
model_validator,
)
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig
from openhands.core.config.mcp_config import MCPConfig as LegacyMCPConfig
from openhands.core.config.utils import load_openhands_config
from openhands.sdk.settings import AgentSettings
from openhands.storage.data_models.secrets import Secrets
def _assign_dotted_value(target: dict[str, Any], dotted_key: str, value: Any) -> None:
current = target
parts = dotted_key.split('.')
for part in parts[:-1]:
current = current.setdefault(part, {})
current[parts[-1]] = value
# Maps legacy flat field names → SDK keys for migration.
_LEGACY_FLAT_TO_SDK: dict[str, str] = {
'agent': 'agent',
'llm_model': 'llm.model',
'llm_api_key': 'llm.api_key',
'llm_base_url': 'llm.base_url',
'mcp_config': 'mcp_config',
'confirmation_mode': 'verification.confirmation_mode',
'security_analyzer': 'verification.security_analyzer',
'enable_default_condenser': 'condenser.enabled',
'condenser_max_size': 'condenser.max_size',
'max_iterations': 'max_iterations',
}
@lru_cache(maxsize=1)
def _sdk_schema_field_metadata() -> tuple[set[str], set[str]]:
schema = AgentSettings.export_schema()
field_keys: set[str] = set()
secret_keys: set[str] = set()
for section in schema.sections:
for field in section.fields:
field_keys.add(field.key)
if field.secret:
secret_keys.add(field.key)
return field_keys, secret_keys
def _lookup_dotted_value(source: dict[str, Any], dotted_key: str) -> Any:
current: Any = source
for part in dotted_key.split('.'):
if not isinstance(current, dict) or part not in current:
return None
current = current[part]
return current
def _normalize_persisted_sdk_value(dotted_key: str, value: Any) -> Any:
normalized_value = _coerce_agent_setting_value(value)
if dotted_key == 'llm.model' and isinstance(normalized_value, str):
if normalized_value.startswith('openhands/'):
return normalized_value
if normalized_value.startswith('litellm_proxy/'):
return f'openhands/{normalized_value.removeprefix("litellm_proxy/")}'
return normalized_value
@lru_cache(maxsize=1)
def _default_agent_settings_payload() -> dict[str, Any]:
return AgentSettings().model_dump(mode='python', exclude_none=True)
@lru_cache(maxsize=1)
def _sdk_uses_typed_mcp_config() -> bool:
annotation = AgentSettings.model_fields['mcp_config'].annotation
if annotation is SDKMCPConfig:
return True
origin = get_origin(annotation)
if origin is not None:
return SDKMCPConfig in get_args(annotation)
return False
def _coerce_agent_setting_value(value: Any) -> Any:
if isinstance(value, SecretStr):
return value.get_secret_value()
if isinstance(value, SDKMCPConfig):
return value.model_dump(exclude_none=True, exclude_defaults=True)
if isinstance(value, LegacyMCPConfig):
return value.model_dump(mode='python')
return value
def _legacy_mcp_config_to_sdk(value: LegacyMCPConfig) -> SDKMCPConfig | None:
mcp_servers: dict[str, Any] = {}
for index, sse_server in enumerate(value.sse_servers):
sse_server_config: dict[str, Any] = {
'transport': 'sse',
'url': sse_server.url,
}
if sse_server.api_key:
sse_server_config['auth'] = sse_server.api_key
mcp_servers[f'sse_{index}'] = sse_server_config
for index, shttp_server in enumerate(value.shttp_servers):
shttp_server_config: dict[str, Any] = {
'transport': 'http',
'url': shttp_server.url,
}
if shttp_server.api_key:
shttp_server_config['auth'] = shttp_server.api_key
if shttp_server.timeout is not None:
shttp_server_config['timeout'] = shttp_server.timeout
mcp_servers[f'shttp_{index}'] = shttp_server_config
for stdio_server in value.stdio_servers:
stdio_server_config: dict[str, Any] = {'command': stdio_server.command}
if stdio_server.args:
stdio_server_config['args'] = list(stdio_server.args)
if stdio_server.env:
stdio_server_config['env'] = dict(stdio_server.env)
mcp_servers[stdio_server.name] = stdio_server_config
if not mcp_servers:
return None
return SDKMCPConfig.model_validate({'mcpServers': mcp_servers})
def _sdk_mcp_config_to_legacy(value: SDKMCPConfig) -> LegacyMCPConfig:
raw_config = value.model_dump(exclude_none=True)
sse_servers: list[dict[str, Any]] = []
shttp_servers: list[dict[str, Any]] = []
stdio_servers: list[dict[str, Any]] = []
for server_name, server_config in raw_config.get('mcpServers', {}).items():
url = server_config.get('url')
if url:
transport = server_config.get('transport')
if transport is None:
transport = 'sse' if '/sse' in str(url).lower() else 'http'
legacy_server: dict[str, Any] = {'url': url}
auth = server_config.get('auth')
if isinstance(auth, str) and auth != 'oauth':
legacy_server['api_key'] = auth
if transport == 'sse':
sse_servers.append(legacy_server)
continue
if server_config.get('timeout') is not None:
legacy_server['timeout'] = server_config['timeout']
shttp_servers.append(legacy_server)
continue
stdio_server: dict[str, Any] = {
'name': server_name,
'command': server_config['command'],
}
if server_config.get('args'):
stdio_server['args'] = server_config['args']
if server_config.get('env'):
stdio_server['env'] = server_config['env']
stdio_servers.append(stdio_server)
return LegacyMCPConfig.model_validate(
{
'sse_servers': sse_servers,
'shttp_servers': shttp_servers,
'stdio_servers': stdio_servers,
}
)
def _sdk_mcp_config_from_value(value: Any) -> SDKMCPConfig | None:
if value in (None, {}):
return None
if isinstance(value, SDKMCPConfig):
return value if value.mcpServers else None
if isinstance(value, LegacyMCPConfig):
return _legacy_mcp_config_to_sdk(value)
if isinstance(value, dict) and 'mcpServers' in value:
if not value.get('mcpServers'):
return None
return SDKMCPConfig.model_validate(value)
return _legacy_mcp_config_to_sdk(LegacyMCPConfig.model_validate(value))
def _merge_sdk_mcp_configs(
base_config: SDKMCPConfig | None, extra_config: SDKMCPConfig | None
) -> SDKMCPConfig | None:
if base_config is None:
return extra_config
if extra_config is None:
return base_config
merged_servers: dict[str, Any] = {}
def _add_server(server_name: str, server_config: dict[str, Any]) -> None:
candidate = server_name or 'server'
if candidate not in merged_servers:
merged_servers[candidate] = server_config
return
suffix = 1
while f'{candidate}_{suffix}' in merged_servers:
suffix += 1
merged_servers[f'{candidate}_{suffix}'] = server_config
for config in (base_config, extra_config):
raw_config = _coerce_agent_setting_value(config)
for server_name, server_config in raw_config.get('mcpServers', {}).items():
_add_server(server_name, server_config)
if not merged_servers:
return None
return SDKMCPConfig.model_validate({'mcpServers': merged_servers})
def _legacy_mcp_config_from_value(value: Any) -> LegacyMCPConfig | None:
if value in (None, {}):
return None
if isinstance(value, LegacyMCPConfig):
return value
if isinstance(value, SDKMCPConfig):
return _sdk_mcp_config_to_legacy(value)
if isinstance(value, dict) and 'mcpServers' in value:
return _sdk_mcp_config_to_legacy(SDKMCPConfig.model_validate(value))
return LegacyMCPConfig.model_validate(value)
def _normalize_agent_setting_value(key: str, value: Any) -> Any:
if key == 'mcp_config':
sdk_mcp_config = _sdk_mcp_config_from_value(value)
if sdk_mcp_config is None:
return None
return _coerce_agent_setting_value(sdk_mcp_config)
return _coerce_agent_setting_value(value)
def _build_sdk_agent_settings_payload(agent_settings: dict[str, Any]) -> dict[str, Any]:
payload: dict[str, Any] = {}
for key, value in agent_settings.items():
if key == 'schema_version':
continue
normalized_value = _coerce_agent_setting_value(value)
if key == 'mcp_config':
sdk_mcp_config = _sdk_mcp_config_from_value(normalized_value)
if sdk_mcp_config is None:
normalized_value = None if _sdk_uses_typed_mcp_config() else {}
elif _sdk_uses_typed_mcp_config():
normalized_value = sdk_mcp_config
else:
normalized_value = sdk_mcp_config.model_dump(
exclude_none=True, exclude_defaults=True
)
_assign_dotted_value(payload, key, normalized_value)
return payload
class SandboxGroupingStrategy(str, Enum):
"""Strategy for grouping conversations within sandboxes."""
NO_GROUPING = 'NO_GROUPING' # Default - each conversation gets its own sandbox
GROUP_BY_NEWEST = 'GROUP_BY_NEWEST' # Add to the most recently created sandbox
LEAST_RECENTLY_USED = (
'LEAST_RECENTLY_USED' # Add to the least recently used sandbox
)
FEWEST_CONVERSATIONS = (
'FEWEST_CONVERSATIONS' # Add to sandbox with fewest conversations
)
ADD_TO_ANY = 'ADD_TO_ANY' # Add to any available sandbox (first found)
NO_GROUPING = 'NO_GROUPING'
GROUP_BY_NEWEST = 'GROUP_BY_NEWEST'
LEAST_RECENTLY_USED = 'LEAST_RECENTLY_USED'
FEWEST_CONVERSATIONS = 'FEWEST_CONVERSATIONS'
ADD_TO_ANY = 'ADD_TO_ANY'
class Settings(BaseModel):
"""Persisted settings for OpenHands sessions"""
"""Persisted settings for OpenHands sessions.
SDK-managed fields (agent, llm, mcp, condenser, verification) live
exclusively in ``agent_settings``. Non-agent product settings remain as
top-level fields on this model.
"""
language: str | None = None
agent: str | None = None
max_iterations: int | None = None
security_analyzer: str | None = None
confirmation_mode: bool | None = None
llm_model: str | None = None
llm_api_key: SecretStr | None = None
llm_base_url: str | None = None
user_version: int | None = None
remote_runtime_resource_factor: int | None = None
# Planned to be removed from settings
secrets_store: Annotated[Secrets, Field(frozen=True)] = Field(
default_factory=Secrets
)
enable_default_condenser: bool = True
enable_sound_notifications: bool = False
enable_proactive_conversation_starters: bool = True
enable_solvability_analysis: bool = True
user_consents_to_analytics: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
mcp_config: MCPConfig | None = None
mcp_config: LegacyMCPConfig | None = None
disabled_skills: list[str] | None = None
search_api_key: SecretStr | None = None
sandbox_api_key: SecretStr | None = None
max_budget_per_task: float | None = None
# Maximum number of events in the conversation view before condensation runs
condenser_max_size: int | None = None
email: str | None = None
email_verified: bool | None = None
git_user_name: str | None = None
git_user_email: str | None = None
v1_enabled: bool = True
raw_agent_settings: dict[str, Any] = Field(
default_factory=dict, alias='agent_settings'
)
sandbox_grouping_strategy: SandboxGroupingStrategy = (
SandboxGroupingStrategy.NO_GROUPING
)
model_config = ConfigDict(
validate_assignment=True,
validate_assignment=True, populate_by_name=True, serialize_by_alias=True
)
_agent_settings: AgentSettings = PrivateAttr(default_factory=AgentSettings)
@field_serializer('llm_api_key', 'search_api_key')
@property
def agent_settings(self) -> AgentSettings:
return self._agent_settings
def to_legacy_mcp_config(self) -> LegacyMCPConfig | None:
return _legacy_mcp_config_from_value(self.agent_settings.mcp_config)
def _reload_agent_settings(self) -> None:
payload = _build_sdk_agent_settings_payload(self.raw_agent_settings)
self._agent_settings = AgentSettings.model_validate(payload)
def get_agent_setting(self, key: str, default: Any = None) -> Any:
return self.raw_agent_settings.get(key, default)
def set_agent_setting(self, key: str, value: Any) -> None:
if value is None:
self.raw_agent_settings.pop(key, None)
else:
self.raw_agent_settings[key] = _normalize_agent_setting_value(key, value)
self.normalize_agent_settings()
def get_secret_agent_setting(self, key: str) -> SecretStr | None:
value = self.raw_agent_settings.get(key)
if not value:
return None
return SecretStr(str(value))
@property
def llm_api_key_is_set(self) -> bool:
val = self.raw_agent_settings.get('llm.api_key')
return bool(val and str(val).strip())
def agent_settings_values(
self, *, strip_secret_values: bool = False
) -> dict[str, Any]:
field_keys, secret_keys = _sdk_schema_field_metadata()
payload = self.agent_settings.model_dump(mode='python', exclude_none=True)
default_payload = _default_agent_settings_payload()
values = {
key: value
for key, value in self.raw_agent_settings.items()
if key not in field_keys and key != 'schema_version'
}
values['schema_version'] = self.raw_agent_settings.get('schema_version', 1)
for key in field_keys:
value = _lookup_dotted_value(payload, key)
if value is None:
continue
default_value = _lookup_dotted_value(default_payload, key)
if key not in self.raw_agent_settings and value == default_value:
continue
if strip_secret_values and key in secret_keys:
continue
values[key] = _normalize_persisted_sdk_value(key, value)
return values
def normalized_agent_settings(
self, *, strip_secret_values: bool = False
) -> dict[str, Any]:
"""Return a canonical flat agent_settings mapping for persistence."""
return self.agent_settings_values(strip_secret_values=strip_secret_values)
def normalize_agent_settings(self, *, strip_secret_values: bool = False) -> bool:
self._reload_agent_settings()
normalized = self.normalized_agent_settings(
strip_secret_values=strip_secret_values
)
changed = normalized != self.raw_agent_settings
if changed:
object.__setattr__(self, 'raw_agent_settings', normalized)
return changed
@field_serializer('search_api_key')
def api_key_serializer(self, api_key: SecretStr | None, info: SerializationInfo):
"""Custom serializer for API keys.
To serialize the API key instead of ********, set expose_secrets to True in the serialization context.
"""
if api_key is None:
return None
# Get the secret value to check if it's empty
secret_value = api_key.get_secret_value()
if not secret_value or not secret_value.strip():
return None
context = info.context
if context and context.get('expose_secrets', False):
return secret_value
return str(api_key)
@field_serializer('raw_agent_settings')
def raw_agent_settings_field_serializer(
self, values: dict[str, Any], info: SerializationInfo
) -> dict[str, Any]:
"""Expose secret SDK values only when ``expose_secrets`` is set."""
context = info.context
if context and context.get('expose_secrets', False):
return values
_, secret_keys = _sdk_schema_field_metadata()
serialized: dict[str, Any] = {}
for key, value in values.items():
if key in secret_keys and value and value != '<hidden>':
serialized[key] = str(SecretStr(str(value)))
else:
serialized[key] = value
return serialized
@model_validator(mode='before')
@classmethod
def convert_provider_tokens(cls, data: dict | object) -> dict | object:
"""Convert provider tokens from JSON format to Secrets format."""
def _migrate_legacy_fields(cls, data: dict | object) -> dict | object:
"""Migrate legacy flat fields into ``agent_settings``."""
if not isinstance(data, dict):
return data
raw_agent_settings = data.pop('raw_agent_settings', None)
agent_settings = data.pop('agent_settings', None)
agent_vals: dict[str, Any] = dict(raw_agent_settings or agent_settings or {})
for legacy_key in ('sdk_settings_values', 'mcp_config'):
legacy_agent_vals = data.pop(legacy_key, None)
if legacy_key == 'sdk_settings_values' and isinstance(
legacy_agent_vals, dict
):
for key, value in legacy_agent_vals.items():
agent_vals.setdefault(
key, _normalize_agent_setting_value(key, value)
)
elif legacy_key == 'mcp_config' and legacy_agent_vals is not None:
agent_vals.setdefault(
'mcp_config',
_normalize_agent_setting_value('mcp_config', legacy_agent_vals),
)
for flat_key, sdk_key in _LEGACY_FLAT_TO_SDK.items():
if flat_key in data and sdk_key not in agent_vals:
value = data[flat_key]
if value is not None:
if isinstance(value, str) and value.startswith('**'):
continue
agent_vals[sdk_key] = _normalize_agent_setting_value(sdk_key, value)
for flat_key in _LEGACY_FLAT_TO_SDK:
data.pop(flat_key, None)
data['raw_agent_settings'] = agent_vals
secrets_store = data.get('secrets_store')
if not isinstance(secrets_store, dict):
return data
if isinstance(secrets_store, dict):
custom_secrets = secrets_store.get('custom_secrets')
tokens = secrets_store.get('provider_tokens')
secret_store = Secrets(provider_tokens={}, custom_secrets={}) # type: ignore[arg-type]
if isinstance(tokens, dict):
converted_store = Secrets(provider_tokens=tokens) # type: ignore[arg-type]
secret_store = secret_store.model_copy(
update={'provider_tokens': converted_store.provider_tokens}
)
if isinstance(custom_secrets, dict):
converted_store = Secrets(custom_secrets=custom_secrets) # type: ignore[arg-type]
secret_store = secret_store.model_copy(
update={'custom_secrets': converted_store.custom_secrets}
)
data['secret_store'] = secret_store
custom_secrets = secrets_store.get('custom_secrets')
tokens = secrets_store.get('provider_tokens')
secret_store = Secrets(provider_tokens={}, custom_secrets={}) # type: ignore[arg-type]
if isinstance(tokens, dict):
converted_store = Secrets(provider_tokens=tokens) # type: ignore[arg-type]
secret_store = secret_store.model_copy(
update={'provider_tokens': converted_store.provider_tokens}
)
else:
secret_store.model_copy(update={'provider_tokens': tokens})
if isinstance(custom_secrets, dict):
converted_store = Secrets(custom_secrets=custom_secrets) # type: ignore[arg-type]
secret_store = secret_store.model_copy(
update={'custom_secrets': converted_store.custom_secrets}
)
else:
secret_store = secret_store.model_copy(
update={'custom_secrets': custom_secrets}
)
data['secret_store'] = secret_store
return data
@field_validator('condenser_max_size')
@classmethod
def validate_condenser_max_size(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 20:
raise ValueError('condenser_max_size must be at least 20')
return v
@model_validator(mode='after')
def _normalize_agent_settings_after(self) -> 'Settings':
self.normalize_agent_settings()
return self
@field_serializer('secrets_store')
def secrets_store_serializer(self, secrets: Secrets, info: SerializationInfo):
"""Custom serializer for secrets store."""
"""Force invalidate secret store"""
return {'provider_tokens': {}}
@staticmethod
@@ -154,57 +505,50 @@ class Settings(BaseModel):
app_config = load_openhands_config()
llm_config: LLMConfig = app_config.get_llm_config()
if llm_config.api_key is None:
# If no api key has been set, we take this to mean that there is no reasonable default
return None
security = app_config.security
# Get MCP config if available
mcp_config = None
if hasattr(app_config, 'mcp'):
mcp_config = app_config.mcp
settings = Settings(
language='en',
agent=app_config.default_agent,
max_iterations=app_config.max_iterations,
security_analyzer=security.security_analyzer,
confirmation_mode=security.confirmation_mode,
llm_model=llm_config.model,
llm_api_key=llm_config.api_key,
llm_base_url=llm_config.base_url,
remote_runtime_resource_factor=app_config.sandbox.remote_runtime_resource_factor,
mcp_config=mcp_config,
search_api_key=app_config.search_api_key,
max_budget_per_task=app_config.max_budget_per_task,
)
settings.set_agent_setting('agent', app_config.default_agent)
settings.set_agent_setting('llm.model', llm_config.model)
settings.set_agent_setting('llm.api_key', llm_config.api_key)
settings.set_agent_setting('llm.base_url', llm_config.base_url)
settings.set_agent_setting(
'verification.confirmation_mode',
app_config.security.confirmation_mode,
)
settings.set_agent_setting(
'verification.security_analyzer',
app_config.security.security_analyzer,
)
settings.set_agent_setting('max_iterations', app_config.max_iterations)
if hasattr(app_config, 'mcp'):
settings.set_agent_setting('mcp_config', app_config.mcp)
return settings
def merge_with_config_settings(self) -> 'Settings':
"""Merge config.toml settings with stored settings.
Config.toml takes priority for MCP settings, but they are merged rather than replaced.
This method can be used by both server mode and CLI mode.
"""
# Get config.toml settings
"""Merge config.toml MCP settings with stored SDK agent_settings."""
config_settings = Settings.from_config()
if not config_settings or not config_settings.mcp_config:
if not config_settings:
return self
# If stored settings don't have MCP config, use config.toml MCP config
if not self.mcp_config:
self.mcp_config = config_settings.mcp_config
return self
# Both have MCP config - merge them with config.toml taking priority
merged_mcp = MCPConfig(
sse_servers=list(config_settings.mcp_config.sse_servers)
+ list(self.mcp_config.sse_servers),
stdio_servers=list(config_settings.mcp_config.stdio_servers)
+ list(self.mcp_config.stdio_servers),
shttp_servers=list(config_settings.mcp_config.shttp_servers)
+ list(self.mcp_config.shttp_servers),
merged_mcp = _merge_sdk_mcp_configs(
_sdk_mcp_config_from_value(
config_settings.raw_agent_settings.get('mcp_config')
),
_sdk_mcp_config_from_value(self.raw_agent_settings.get('mcp_config')),
)
if merged_mcp is None:
return self
# Create new settings with merged MCP config
self.mcp_config = merged_mcp
self.raw_agent_settings['mcp_config'] = _coerce_agent_setting_value(merged_mcp)
self.normalize_agent_settings()
return self
def to_agent_settings(self) -> AgentSettings:
"""Return the cached SDK ``AgentSettings`` model."""
return self.agent_settings

View File

@@ -113,16 +113,16 @@ async def auto_generate_title(
if first_user_message:
# Get LLM config from user settings
try:
if settings and settings.llm_model:
# Create LLM config from settings
settings_base_url = settings.llm_base_url
if settings:
agent_settings = settings.agent_settings
settings_base_url = agent_settings.llm.base_url
effective_base_url = get_effective_llm_base_url(
settings.llm_model,
agent_settings.llm.model,
settings_base_url,
)
llm_config = LLMConfig(
model=settings.llm_model,
api_key=settings.llm_api_key,
model=agent_settings.llm.model,
api_key=agent_settings.llm.api_key,
base_url=effective_base_url,
)

View File

@@ -13,11 +13,12 @@ def setup_llm_config(config: OpenHandsConfig, settings: Settings) -> OpenHandsCo
# Copying this means that when we update variables they are not applied to the shared global configuration!
config = deepcopy(config)
agent_settings = settings.agent_settings
llm_config = config.get_llm_config()
llm_config.model = settings.llm_model or ''
llm_config.api_key = settings.llm_api_key
llm_config.model = agent_settings.llm.model
llm_config.api_key = agent_settings.llm.api_key
env_base_url = os.environ.get('LLM_BASE_URL')
settings_base_url = settings.llm_base_url
settings_base_url = agent_settings.llm.base_url
# Use env_base_url if available, otherwise fall back to settings_base_url
base_url_to_use = (
@@ -43,7 +44,7 @@ def create_registry_and_conversation_stats(
if user_settings:
user_config = setup_llm_config(config, user_settings)
agent_cls = user_settings.agent if user_settings else None
agent_cls = user_settings.agent_settings.agent if user_settings else None
llm_registry = LLMRegistry(user_config, agent_cls)
file_store = get_file_store(
file_store_type=config.file_store,

View File

@@ -57,9 +57,9 @@ dependencies = [
"numpy",
"openai==2.8",
"openhands-aci==0.3.3",
"openhands-agent-server==1.14",
"openhands-sdk==1.14",
"openhands-tools==1.14",
"openhands-agent-server @ git+https://github.com/OpenHands/software-agent-sdk.git@openhands/issue-2228-sdk-settings-schema#subdirectory=openhands-agent-server",
"openhands-sdk @ git+https://github.com/OpenHands/software-agent-sdk.git@openhands/issue-2228-sdk-settings-schema#subdirectory=openhands-sdk",
"openhands-tools @ git+https://github.com/OpenHands/software-agent-sdk.git@openhands/issue-2228-sdk-settings-schema#subdirectory=openhands-tools",
"opentelemetry-api>=1.33.1",
"opentelemetry-exporter-otlp-proto-grpc>=1.33.1",
"orjson>=3.11.6",
@@ -68,7 +68,7 @@ dependencies = [
"pg8000>=1.31.5",
"pillow>=12.1.1",
"playwright>=1.55",
"poetry>=2.1.2",
"poetry>=2.3.2",
"prompt-toolkit>=3.0.50",
"protobuf>=5.29.6,<6",
"psutil",
@@ -209,7 +209,7 @@ sse-starlette = "^3.0.2"
psutil = "*"
python-json-logger = "^3.2.1"
prompt-toolkit = "^3.0.50"
poetry = "^2.1.2"
poetry = "^2.3.2"
anyio = "4.9.0"
pythonnet = { version = "*", markers = "sys_platform == 'win32'" }
fastmcp = ">=3,<4"
@@ -251,9 +251,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
openhands-sdk = "1.14"
openhands-agent-server = "1.14"
openhands-tools = "1.14"
openhands-sdk = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-sdk" }
openhands-agent-server = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-agent-server" }
openhands-tools = { git = "https://github.com/OpenHands/software-agent-sdk.git", branch = "openhands/issue-2228-sdk-settings-schema", subdirectory = "openhands-tools" }
jwcrypto = ">=1.5.6"
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"

View File

@@ -5,7 +5,8 @@ import json
import os
import zipfile
from datetime import datetime
from unittest.mock import AsyncMock, Mock, patch
from types import SimpleNamespace
from unittest.mock import ANY, AsyncMock, Mock, patch
from uuid import uuid4
import pytest
@@ -37,13 +38,71 @@ from openhands.app_server.user.user_context import UserContext
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.integrations.service_types import SuggestedTask, TaskType
from openhands.sdk import Agent, Event
from openhands.sdk.critic.impl.api import APIBasedCritic
from openhands.sdk.llm import LLM
from openhands.sdk.secret import LookupSecret, StaticSecret
from openhands.sdk.settings import AgentSettings
from openhands.sdk.workspace import LocalWorkspace
from openhands.sdk.workspace.remote.async_remote_workspace import AsyncRemoteWorkspace
from openhands.server.types import AppMode
from openhands.storage.data_models.conversation_metadata import ConversationTrigger
from openhands.storage.data_models.settings import SandboxGroupingStrategy
from openhands.storage.data_models.settings import SandboxGroupingStrategy, Settings
def _build_test_user_agent_settings(user: SimpleNamespace) -> AgentSettings:
agent_vals = dict(getattr(user, 'raw_agent_settings', {}))
model = getattr(user, 'llm_model', '') or ''
agent_vals.setdefault('llm.model', model)
llm_api_key = getattr(user, 'llm_api_key', None)
if llm_api_key:
agent_vals.setdefault('llm.api_key', llm_api_key)
mcp_config = getattr(user, 'mcp_config', None)
if mcp_config and 'mcp_config' not in agent_vals:
agent_vals['mcp_config'] = mcp_config.model_dump(mode='python')
llm_base_url = getattr(user, 'llm_base_url', None)
if (
llm_base_url
and 'llm.base_url' not in agent_vals
and not model.startswith('openhands/')
):
agent_vals['llm.base_url'] = llm_base_url
return Settings(agent_settings=agent_vals).agent_settings
class _TestUserInfo(SimpleNamespace):
@property
def agent_settings(self) -> AgentSettings:
override = getattr(self, '_agent_settings_override', None)
if override is not None:
return override
return _build_test_user_agent_settings(self)
@agent_settings.setter
def agent_settings(self, value):
self.raw_agent_settings = value
@property
def raw_agent_settings(self) -> dict:
return getattr(self, '_raw_agent_settings', {})
@raw_agent_settings.setter
def raw_agent_settings(self, value):
object.__setattr__(self, '_raw_agent_settings', value or {})
def to_legacy_mcp_config(self):
agent_vals = dict(self.raw_agent_settings)
mcp_config = getattr(self, 'mcp_config', None)
if mcp_config and 'mcp_config' not in agent_vals:
agent_vals['mcp_config'] = mcp_config.model_dump(mode='python')
return Settings(agent_settings=agent_vals).to_legacy_mcp_config()
def to_agent_settings(self) -> AgentSettings:
return self.agent_settings
# Env var used by openhands SDK LLM to skip context-window validation (e.g. for gpt-4 in tests)
_ALLOW_SHORT_CONTEXT_WINDOWS = 'ALLOW_SHORT_CONTEXT_WINDOWS'
@@ -105,18 +164,18 @@ class TestLiveStatusAppConversationService:
)
# Mock user info
self.mock_user = Mock()
self.mock_user.id = 'test_user_123'
self.mock_user.llm_model = 'gpt-4'
self.mock_user.llm_base_url = 'https://api.openai.com/v1'
self.mock_user.llm_api_key = 'test_api_key'
# Use ADD_TO_ANY for tests to maintain old behavior
self.mock_user.sandbox_grouping_strategy = SandboxGroupingStrategy.ADD_TO_ANY
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'
self.mock_user.mcp_config = None # Default to None to avoid error handling path
self.mock_user = _TestUserInfo(
id='test_user_123',
llm_model='gpt-4',
llm_base_url='https://api.openai.com/v1',
llm_api_key='test_api_key',
sandbox_grouping_strategy=SandboxGroupingStrategy.ADD_TO_ANY,
confirmation_mode=False,
search_api_key=None,
condenser_max_size=None,
mcp_config=None,
agent_settings={},
)
# Mock sandbox
self.mock_sandbox = Mock(spec=SandboxInfo)
@@ -494,8 +553,30 @@ class TestLiveStatusAppConversationService:
)
@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."""
async def test_configure_llm_and_mcp_uses_sdk_agent_settings(self):
"""SDK AgentSettings values should drive the configured LLM."""
self.mock_user.agent_settings = {
'llm.model': 'sdk-model',
'llm.base_url': 'https://sdk-llm.example.com',
'llm.timeout': 123,
'llm.temperature': 0.3,
'llm.max_input_tokens': 456,
}
self.mock_user_context.get_mcp_api_key.return_value = None
llm, _ = await self.service._configure_llm_and_mcp(self.mock_user, None)
assert llm.model == 'sdk-model'
assert llm.base_url == 'https://sdk-llm.example.com'
assert llm.timeout == 123
assert llm.temperature == 0.3
assert llm.max_input_tokens == 456
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_openhands_model_uses_sdk_default_proxy_url(
self,
):
"""openhands/* model follows the SDK-managed default proxy URL."""
# Arrange
self.mock_user.llm_model = 'openhands/special'
self.mock_user.llm_base_url = 'https://user-llm.example.com'
@@ -507,11 +588,13 @@ class TestLiveStatusAppConversationService:
)
# Assert
assert llm.base_url == 'https://user-llm.example.com'
assert llm.base_url == 'https://llm-proxy.app.all-hands.dev/'
@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."""
async def test_configure_llm_and_mcp_openhands_model_ignores_provider_base_url(
self,
):
"""openhands/* model follows the SDK proxy URL even when a provider URL exists."""
# Arrange
self.mock_user.llm_model = 'openhands/default'
self.mock_user.llm_base_url = None
@@ -523,11 +606,11 @@ class TestLiveStatusAppConversationService:
)
# Assert
assert llm.base_url == 'https://provider.example.com'
assert llm.base_url == 'https://llm-proxy.app.all-hands.dev/'
@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."""
"""openhands/* model still uses the SDK proxy when no other URLs exist."""
# Arrange
self.mock_user.llm_model = 'openhands/default'
self.mock_user.llm_base_url = None
@@ -806,242 +889,127 @@ class TestLiveStatusAppConversationService:
# Assert
assert path == '/workspace/project/agents-tmp-config/PLAN.md'
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools'
)
@patch(
'openhands.app_server.app_conversation.app_conversation_service_base.AppConversationServiceBase._create_condenser'
)
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.format_plan_structure'
)
def test_create_agent_with_context_planning_agent(
self, mock_format_plan, mock_create_condenser, mock_get_tools
):
"""Test _create_agent_with_context for planning agent type."""
# Arrange
mock_llm = Mock(spec=LLM)
mock_llm.model_copy.return_value = mock_llm
mock_get_tools.return_value = []
mock_condenser = Mock()
mock_create_condenser.return_value = mock_condenser
mock_format_plan.return_value = 'test_plan_structure'
mcp_config = {'default': {'url': 'test'}}
system_message_suffix = 'Test suffix'
working_dir = '/workspace/project'
git_provider = ProviderType.GITHUB
def test_get_agent_settings_passes_through_critic_settings(self):
"""_get_agent_settings passes critic settings through unchanged."""
self.mock_user.agent_settings = {
'llm.model': 'openhands/default',
'verification.critic_enabled': True,
'verification.critic_server_url': 'https://my-critic.example.com',
'verification.critic_model_name': 'my-critic',
}
# Act
with patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.Agent'
) as mock_agent_class:
mock_agent_instance = Mock()
mock_agent_instance.model_copy.return_value = mock_agent_instance
mock_agent_class.return_value = mock_agent_instance
settings = self.service._get_agent_settings(self.mock_user, None)
self.service._create_agent_with_context(
mock_llm,
AgentType.PLAN,
system_message_suffix,
mcp_config,
self.mock_user.condenser_max_size,
git_provider=git_provider,
working_dir=working_dir,
)
# Assert
mock_get_tools.assert_called_once_with(
plan_path='/workspace/project/.agents_tmp/PLAN.md'
)
mock_agent_class.assert_called_once()
call_kwargs = mock_agent_class.call_args[1]
assert call_kwargs['llm'] == mock_llm
assert call_kwargs['system_prompt_filename'] == 'system_prompt_planning.j2'
assert (
call_kwargs['system_prompt_kwargs']['plan_structure']
== 'test_plan_structure'
)
assert call_kwargs['mcp_config'] == mcp_config
assert call_kwargs['security_analyzer'] is None
assert call_kwargs['condenser'] == mock_condenser
mock_create_condenser.assert_called_once_with(
mock_llm, AgentType.PLAN, self.mock_user.condenser_max_size
)
assert settings.verification.critic_enabled is True
assert (
settings.verification.critic_server_url == 'https://my-critic.example.com'
)
assert settings.verification.critic_model_name == 'my-critic'
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_default_tools'
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools',
return_value=[],
)
@patch(
'openhands.app_server.app_conversation.app_conversation_service_base.AppConversationServiceBase._create_condenser'
)
def test_create_agent_with_context_default_agent(
self, mock_create_condenser, mock_get_tools
):
"""Test _create_agent_with_context for default agent type."""
# Arrange
mock_llm = Mock(spec=LLM)
mock_llm.model_copy.return_value = mock_llm
mock_get_tools.return_value = []
mock_condenser = Mock()
mock_create_condenser.return_value = mock_condenser
def test_create_agent_planning_agent(self, _mock_get_tools):
"""Planning agent gets planning tools, prompt overrides, and instruction."""
llm = LLM(model='test-model', api_key=SecretStr('k'))
settings = AgentSettings(llm=LLM(model='test-model'))
mcp_config = {'default': {'url': 'test'}}
# Act
with patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.Agent'
) as mock_agent_class:
mock_agent_instance = Mock()
mock_agent_instance.model_copy.return_value = mock_agent_instance
mock_agent_class.return_value = mock_agent_instance
agent = self.service._create_agent(
llm,
AgentType.PLAN,
'Test suffix',
mcp_config,
working_dir='/workspace/project',
git_provider=ProviderType.GITHUB,
agent_settings=settings,
)
self.service._create_agent_with_context(
mock_llm,
AgentType.DEFAULT,
None,
mcp_config,
self.mock_user.condenser_max_size,
)
# Assert
mock_agent_class.assert_called_once()
call_kwargs = mock_agent_class.call_args[1]
assert call_kwargs['llm'] == mock_llm
assert call_kwargs['system_prompt_kwargs']['cli_mode'] is False
assert call_kwargs['mcp_config'] == mcp_config
assert call_kwargs['condenser'] == mock_condenser
mock_get_tools.assert_called_once_with(enable_browser=True)
mock_create_condenser.assert_called_once_with(
mock_llm, AgentType.DEFAULT, self.mock_user.condenser_max_size
)
assert agent.system_prompt_filename == 'system_prompt_planning.j2'
assert 'plan_structure' in agent.system_prompt_kwargs
assert agent.mcp_config == mcp_config
assert agent.agent_context is not None
assert agent.agent_context.system_message_suffix.startswith(
PLANNING_AGENT_INSTRUCTION
)
assert 'Test suffix' in agent.agent_context.system_message_suffix
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools'
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_default_tools',
return_value=[],
)
@patch(
'openhands.app_server.app_conversation.app_conversation_service_base.AppConversationServiceBase._create_condenser'
)
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.format_plan_structure'
)
def test_create_agent_with_context_planning_agent_applies_instruction(
self, mock_format_plan, mock_create_condenser, mock_get_tools
):
"""Test _create_agent_with_context applies PLANNING_AGENT_INSTRUCTION for plan agents."""
# Arrange
mock_llm = Mock(spec=LLM)
mock_llm.model_copy.return_value = mock_llm
mock_get_tools.return_value = []
mock_condenser = Mock()
mock_create_condenser.return_value = mock_condenser
mock_format_plan.return_value = 'test_plan_structure'
mcp_config = {}
def test_create_agent_default_agent(self, _mock_get_tools):
"""Default agent gets default tools and cli_mode=False."""
llm = LLM(model='test-model', api_key=SecretStr('k'))
settings = AgentSettings(llm=LLM(model='test-model'))
# Act
with patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.Agent'
) as mock_agent_class:
mock_agent_instance = Mock()
mock_agent_instance.model_copy.return_value = mock_agent_instance
mock_agent_class.return_value = mock_agent_instance
agent = self.service._create_agent(
llm,
AgentType.DEFAULT,
None,
{},
agent_settings=settings,
)
self.service._create_agent_with_context(
mock_llm,
AgentType.PLAN,
None, # No existing suffix
mcp_config,
self.mock_user.condenser_max_size,
)
# Assert - verify model_copy was called with agent_context containing planning instruction
model_copy_call = mock_agent_instance.model_copy.call_args
agent_context = model_copy_call[1]['update']['agent_context']
assert agent_context.system_message_suffix == PLANNING_AGENT_INSTRUCTION
assert agent.system_prompt_kwargs == {'cli_mode': False}
assert agent.agent_context is not None
assert agent.agent_context.system_message_suffix is None
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_planning_tools'
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_default_tools',
return_value=[],
)
@patch(
'openhands.app_server.app_conversation.app_conversation_service_base.AppConversationServiceBase._create_condenser'
)
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.format_plan_structure'
)
def test_create_agent_with_context_planning_agent_prepends_to_existing_suffix(
self, mock_format_plan, mock_create_condenser, mock_get_tools
):
"""Test _create_agent_with_context prepends planning instruction to existing suffix."""
# Arrange
mock_llm = Mock(spec=LLM)
mock_llm.model_copy.return_value = mock_llm
mock_get_tools.return_value = []
mock_condenser = Mock()
mock_create_condenser.return_value = mock_condenser
mock_format_plan.return_value = 'test_plan_structure'
mcp_config = {}
existing_suffix = 'Custom user instruction from integration'
def test_create_agent_applies_sdk_agent_settings(self, _mock_get_tools):
"""Resolved SDK AgentSettings should affect V1 agent startup.
# Act
with patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.Agent'
) as mock_agent_class:
mock_agent_instance = Mock()
mock_agent_instance.model_copy.return_value = mock_agent_instance
mock_agent_class.return_value = mock_agent_instance
Settings are expected to be fully resolved by _get_agent_settings
(critic endpoint, model override, etc.) before reaching
_create_agent.
"""
llm = LLM(
model='openhands/default',
base_url='https://llm-proxy.app.all-hands.dev',
api_key=SecretStr('test_api_key'),
)
# Settings as _get_agent_settings would return them — critic
# endpoint already resolved.
agent_settings = AgentSettings.model_validate(
{
'llm': {
'model': 'openhands/default',
'base_url': 'https://llm-proxy.app.all-hands.dev',
'api_key': 'test_api_key',
},
'condenser': {'enabled': False},
'verification': {
'critic_enabled': True,
'critic_mode': 'all_actions',
'critic_server_url': 'https://llm-proxy.app.all-hands.dev/vllm',
'critic_model_name': 'critic',
'enable_iterative_refinement': True,
'critic_threshold': 0.75,
'max_refinement_iterations': 2,
},
}
)
self.service._create_agent_with_context(
mock_llm,
AgentType.PLAN,
existing_suffix,
mcp_config,
self.mock_user.condenser_max_size,
)
agent = self.service._create_agent(
llm,
AgentType.DEFAULT,
None,
{},
agent_settings=agent_settings,
)
# Assert - verify planning instruction is prepended to existing suffix
model_copy_call = mock_agent_instance.model_copy.call_args
agent_context = model_copy_call[1]['update']['agent_context']
assert agent_context.system_message_suffix.startswith(
PLANNING_AGENT_INSTRUCTION
)
assert existing_suffix in agent_context.system_message_suffix
@patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.get_default_tools'
)
@patch(
'openhands.app_server.app_conversation.app_conversation_service_base.AppConversationServiceBase._create_condenser'
)
def test_create_agent_with_context_default_agent_no_planning_instruction(
self, mock_create_condenser, mock_get_tools
):
"""Test _create_agent_with_context does NOT add planning instruction for default agent."""
# Arrange
mock_llm = Mock(spec=LLM)
mock_llm.model_copy.return_value = mock_llm
mock_get_tools.return_value = []
mock_condenser = Mock()
mock_create_condenser.return_value = mock_condenser
mcp_config = {}
# Act
with patch(
'openhands.app_server.app_conversation.live_status_app_conversation_service.Agent'
) as mock_agent_class:
mock_agent_instance = Mock()
mock_agent_instance.model_copy.return_value = mock_agent_instance
mock_agent_class.return_value = mock_agent_instance
self.service._create_agent_with_context(
mock_llm,
AgentType.DEFAULT,
None,
mcp_config,
self.mock_user.condenser_max_size,
)
# Assert - verify no planning instruction for default agent
model_copy_call = mock_agent_instance.model_copy.call_args
agent_context = model_copy_call[1]['update']['agent_context']
assert agent_context.system_message_suffix is None
assert agent.condenser is None
assert isinstance(agent.critic, APIBasedCritic)
assert agent.critic.mode == 'all_actions'
assert agent.critic.server_url == 'https://llm-proxy.app.all-hands.dev/vllm'
assert agent.critic.model_name == 'critic'
assert agent.critic.iterative_refinement is not None
assert agent.critic.iterative_refinement.success_threshold == 0.75
assert agent.critic.iterative_refinement.max_iterations == 2
@pytest.mark.asyncio
async def test_finalize_conversation_request_with_skills(self):
@@ -1186,7 +1154,8 @@ class TestLiveStatusAppConversationService:
self.service._configure_llm_and_mcp = AsyncMock(
return_value=(mock_llm, mock_mcp_config)
)
self.service._create_agent_with_context = Mock(return_value=mock_agent)
self.service._get_agent_settings = Mock(return_value=Mock(spec=AgentSettings))
self.service._create_agent = Mock(return_value=mock_agent)
self.service._finalize_conversation_request = AsyncMock(
return_value=mock_final_request
)
@@ -1214,18 +1183,21 @@ class TestLiveStatusAppConversationService:
self.service._configure_llm_and_mcp.assert_called_once_with(
self.mock_user, 'gpt-4'
)
self.service._get_agent_settings.assert_called_once_with(
self.mock_user, 'gpt-4'
)
# When selected_repository='test/repo', project_dir is resolved
# to '/test/dir/repo' via get_project_dir. All downstream calls
# to '/test/dir/repo' via get_project_dir. All downstream calls
# (agent context, workspace, skills) must use this path.
self.service._create_agent_with_context.assert_called_once_with(
self.service._create_agent.assert_called_once_with(
mock_llm,
AgentType.DEFAULT,
'Test suffix',
mock_mcp_config,
self.mock_user.condenser_max_size,
secrets=mock_secrets,
git_provider=ProviderType.GITHUB,
working_dir='/test/dir/repo',
agent_settings=ANY,
)
self.service._finalize_conversation_request.assert_called_once()
@@ -1859,12 +1831,15 @@ class TestLiveStatusAppConversationService:
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_custom_config_error_handling(self):
"""Test _configure_llm_and_mcp handles errors in custom MCP config gracefully."""
"""Test _configure_llm_and_mcp handles invalid custom MCP config gracefully."""
# Arrange
self.mock_user.mcp_config = Mock()
# Simulate error when accessing sse_servers
self.mock_user.mcp_config.sse_servers = property(
lambda self: (_ for _ in ()).throw(Exception('Config error'))
invalid_mcp_config = Mock()
invalid_mcp_config.model_dump.return_value = 'not-a-dict'
self.mock_user._agent_settings_override = SimpleNamespace(
mcp_config=invalid_mcp_config
)
self.service._configure_llm = Mock(
return_value=LLM.model_validate({'model': 'gpt-4', 'usage_id': 'agent'})
)
self.mock_user_context.get_mcp_api_key.return_value = None
@@ -1877,7 +1852,6 @@ class TestLiveStatusAppConversationService:
assert isinstance(llm, LLM)
mcp_servers = mcp_config['mcpServers']
assert 'default' in mcp_servers
# Custom servers should not be added due to error
@pytest.mark.asyncio
async def test_configure_llm_and_mcp_sdk_format_with_mcpservers_wrapper(self):
@@ -2143,7 +2117,7 @@ class TestLiveStatusAppConversationService:
return_value=mock_secrets
)
self.service._configure_llm_and_mcp = AsyncMock(return_value=(mock_llm, {}))
self.service._create_agent_with_context = Mock(return_value=mock_agent)
self.service._create_agent = Mock(return_value=mock_agent)
captured = {}
@@ -2180,7 +2154,7 @@ class TestLiveStatusAppConversationService:
self.service._configure_llm_and_mcp = AsyncMock(
return_value=(Mock(spec=LLM), {})
)
self.service._create_agent_with_context = Mock(return_value=Mock(spec=Agent))
self.service._create_agent = Mock(return_value=Mock(spec=Agent))
captured = {}
@@ -2363,16 +2337,18 @@ class TestPluginHandling:
)
# Mock user info
self.mock_user = Mock()
self.mock_user.id = 'test_user_123'
self.mock_user.llm_model = 'gpt-4'
self.mock_user.llm_base_url = 'https://api.openai.com/v1'
self.mock_user.llm_api_key = 'test_api_key'
self.mock_user.confirmation_mode = False
self.mock_user.search_api_key = None
self.mock_user.condenser_max_size = None
self.mock_user.mcp_config = None
self.mock_user.security_analyzer = None
self.mock_user = _TestUserInfo(
id='test_user_123',
llm_model='gpt-4',
llm_base_url='https://api.openai.com/v1',
llm_api_key='test_api_key',
confirmation_mode=False,
search_api_key=None,
condenser_max_size=None,
mcp_config=None,
security_analyzer=None,
agent_settings={},
)
# Mock sandbox
self.mock_sandbox = Mock(spec=SandboxInfo)

View File

@@ -156,9 +156,11 @@ class TestGetCurrentUserExposeSecrets:
"""With valid session key, expose_secrets=true returns unmasked llm_api_key."""
user_info = UserInfo(
id=USER_ID,
llm_model='anthropic/claude-sonnet-4-20250514',
llm_api_key=SecretStr('sk-test-key-123'),
llm_base_url='https://litellm.example.com',
agent_settings={
'llm.model': 'anthropic/claude-sonnet-4-20250514',
'llm.api_key': 'sk-test-key-123',
'llm.base_url': 'https://litellm.example.com',
},
)
mock_context = AsyncMock()
mock_context.get_user_info = AsyncMock(return_value=user_info)
@@ -174,13 +176,13 @@ class TestGetCurrentUserExposeSecrets:
x_session_api_key='valid-key',
)
# JSONResponse — parse the body
import json
body = json.loads(result.body)
assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514'
assert body['llm_api_key'] == 'sk-test-key-123'
assert body['llm_base_url'] == 'https://litellm.example.com'
sdk_vals = body['agent_settings']
assert sdk_vals['llm.model'] == 'anthropic/claude-sonnet-4-20250514'
assert sdk_vals['llm.api_key'] == 'sk-test-key-123'
assert sdk_vals['llm.base_url'] == 'https://litellm.example.com'
async def test_expose_secrets_rejects_missing_session_key(self):
"""expose_secrets=true without X-Session-API-Key is rejected."""
@@ -232,7 +234,7 @@ class TestGetCurrentUserExposeSecrets:
"""Without expose_secrets, llm_api_key is masked (no session key needed)."""
user_info = UserInfo(
id=USER_ID,
llm_api_key=SecretStr('sk-test-key-123'),
agent_settings={'llm.model': 'gpt-4o', 'llm.api_key': 'sk-test-key-123'},
)
mock_context = AsyncMock()
mock_context.get_user_info = AsyncMock(return_value=user_info)
@@ -243,10 +245,9 @@ class TestGetCurrentUserExposeSecrets:
# Returns UserInfo directly (FastAPI will serialize with masking)
assert isinstance(result, UserInfo)
assert result.llm_api_key is not None
# The raw value is still in the object, but serialization masks it
dumped = result.model_dump(mode='json')
assert dumped['llm_api_key'] == '**********'
assert dumped['agent_settings']['llm.api_key'] != 'sk-test-key-123'
assert dumped['agent_settings']['llm.api_key'] == '**********'
# ---------------------------------------------------------------------------
@@ -445,7 +446,10 @@ class TestExposeSecretsIntegration:
"""Bearer token alone cannot expose secrets (no X-Session-API-Key)."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123'))
return_value=UserInfo(
id=USER_ID,
agent_settings={'llm.model': 'gpt-4o', 'llm.api_key': 'sk-secret-123'},
)
)
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
@@ -461,7 +465,10 @@ class TestExposeSecretsIntegration:
"""Invalid session key (no matching sandbox) is rejected."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(id=USER_ID, llm_api_key=SecretStr('sk-secret-123'))
return_value=UserInfo(
id=USER_ID,
agent_settings={'llm.model': 'gpt-4o', 'llm.api_key': 'sk-secret-123'},
)
)
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
@@ -488,7 +495,10 @@ class TestExposeSecretsIntegration:
"""Session key from a different user's sandbox is rejected."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(id='user-A', llm_api_key=SecretStr('sk-secret-123'))
return_value=UserInfo(
id='user-A',
agent_settings={'llm.model': 'gpt-4o', 'llm.api_key': 'sk-secret-123'},
)
)
mock_user_ctx.get_user_id = AsyncMock(return_value='user-A')
@@ -521,9 +531,11 @@ class TestExposeSecretsIntegration:
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(
id=USER_ID,
llm_model='anthropic/claude-sonnet-4-20250514',
llm_api_key=SecretStr('sk-real-secret'),
llm_base_url='https://litellm.example.com',
agent_settings={
'llm.model': 'anthropic/claude-sonnet-4-20250514',
'llm.api_key': 'sk-real-secret',
'llm.base_url': 'https://litellm.example.com',
},
)
)
mock_user_ctx.get_user_id = AsyncMock(return_value=USER_ID)
@@ -549,16 +561,21 @@ class TestExposeSecretsIntegration:
assert response.status_code == 200
body = response.json()
assert body['llm_api_key'] == 'sk-real-secret'
assert body['llm_model'] == 'anthropic/claude-sonnet-4-20250514'
assert body['llm_base_url'] == 'https://litellm.example.com'
sdk_vals = body['agent_settings']
assert sdk_vals['llm.api_key'] == 'sk-real-secret'
assert sdk_vals['llm.model'] == 'anthropic/claude-sonnet-4-20250514'
assert sdk_vals['llm.base_url'] == 'https://litellm.example.com'
def test_default_masks_secrets_via_http(self):
"""Without expose_secrets, secrets are masked even via real HTTP."""
"""Without expose_secrets, secrets are in agent_settings."""
mock_user_ctx = AsyncMock()
mock_user_ctx.get_user_info = AsyncMock(
return_value=UserInfo(
id=USER_ID, llm_api_key=SecretStr('sk-should-be-masked')
id=USER_ID,
agent_settings={
'llm.model': 'gpt-4o',
'llm.api_key': 'sk-should-be-masked',
},
)
)
@@ -569,7 +586,7 @@ class TestExposeSecretsIntegration:
assert response.status_code == 200
body = response.json()
assert body['llm_api_key'] == '**********'
assert body['agent_settings']['llm.api_key'] == '**********'
class TestSandboxSecretsIntegration:

View File

@@ -1,5 +1,6 @@
"""Test MCP settings merging functionality."""
import os
from unittest.mock import patch
import pytest
@@ -12,6 +13,16 @@ from openhands.core.config.mcp_config import (
from openhands.storage.data_models.settings import Settings
@pytest.fixture(autouse=True)
def allow_short_context_windows():
with patch.dict(os.environ, {'ALLOW_SHORT_CONTEXT_WINDOWS': 'true'}, clear=False):
yield
def _mcp_config(settings: Settings) -> MCPConfig | None:
return settings.to_legacy_mcp_config()
@pytest.mark.asyncio
async def test_mcp_settings_merge_config_only():
"""Test merging when only config.toml has MCP settings."""
@@ -29,10 +40,11 @@ async def test_mcp_settings_merge_config_only():
merged_settings = frontend_settings.merge_with_config_settings()
# Should use config.toml MCP settings
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.llm_model == 'gpt-4'
merged_mcp_config = _mcp_config(merged_settings)
assert merged_mcp_config is not None
assert len(merged_mcp_config.sse_servers) == 1
assert merged_mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.get_agent_setting('llm.model') == 'gpt-4'
@pytest.mark.asyncio
@@ -53,10 +65,11 @@ async def test_mcp_settings_merge_frontend_only():
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend MCP settings
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.llm_model == 'gpt-4'
merged_mcp_config = _mcp_config(merged_settings)
assert merged_mcp_config is not None
assert len(merged_mcp_config.sse_servers) == 1
assert merged_mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.get_agent_setting('llm.model') == 'gpt-4'
@pytest.mark.asyncio
@@ -91,16 +104,17 @@ async def test_mcp_settings_merge_both_present():
merged_settings = frontend_settings.merge_with_config_settings()
# Should merge both with config.toml taking priority (appearing first)
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 2
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.mcp_config.sse_servers[1].url == 'http://frontend-server.com'
merged_mcp_config = _mcp_config(merged_settings)
assert merged_mcp_config is not None
assert len(merged_mcp_config.sse_servers) == 2
assert merged_mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_mcp_config.sse_servers[1].url == 'http://frontend-server.com'
assert len(merged_settings.mcp_config.stdio_servers) == 2
assert merged_settings.mcp_config.stdio_servers[0].name == 'config-stdio'
assert merged_settings.mcp_config.stdio_servers[1].name == 'frontend-stdio'
assert len(merged_mcp_config.stdio_servers) == 2
assert merged_mcp_config.stdio_servers[0].name == 'config-stdio'
assert merged_mcp_config.stdio_servers[1].name == 'frontend-stdio'
assert merged_settings.llm_model == 'gpt-4'
assert merged_settings.get_agent_setting('llm.model') == 'gpt-4'
@pytest.mark.asyncio
@@ -121,10 +135,11 @@ async def test_mcp_settings_merge_no_config():
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend settings unchanged
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 1
assert merged_settings.mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.llm_model == 'gpt-4'
merged_mcp_config = _mcp_config(merged_settings)
assert merged_mcp_config is not None
assert len(merged_mcp_config.sse_servers) == 1
assert merged_mcp_config.sse_servers[0].url == 'http://frontend-server.com'
assert merged_settings.get_agent_setting('llm.model') == 'gpt-4'
@pytest.mark.asyncio
@@ -140,5 +155,5 @@ async def test_mcp_settings_merge_neither_present():
merged_settings = frontend_settings.merge_with_config_settings()
# Should keep frontend settings unchanged
assert merged_settings.mcp_config is None
assert merged_settings.llm_model == 'gpt-4'
assert _mcp_config(merged_settings) is None
assert merged_settings.get_agent_setting('llm.model') == 'gpt-4'

View File

@@ -1,5 +1,6 @@
"""Integration test for MCP settings merging in the full flow."""
import os
from unittest.mock import AsyncMock, patch
import pytest
@@ -10,6 +11,16 @@ from openhands.storage.data_models.settings import Settings
from openhands.storage.settings.file_settings_store import FileSettingsStore
@pytest.fixture(autouse=True)
def allow_short_context_windows():
with patch.dict(os.environ, {'ALLOW_SHORT_CONTEXT_WINDOWS': 'true'}, clear=False):
yield
def _mcp_config(settings: Settings) -> MCPConfig | None:
return settings.to_legacy_mcp_config()
@pytest.mark.asyncio
async def test_user_auth_mcp_merging_integration():
"""Test that MCP merging works in the user auth flow."""
@@ -44,13 +55,14 @@ async def test_user_auth_mcp_merging_integration():
# Verify merging worked correctly
assert merged_settings is not None
assert merged_settings.llm_model == 'gpt-4'
assert merged_settings.mcp_config is not None
assert len(merged_settings.mcp_config.sse_servers) == 2
merged_mcp_config = _mcp_config(merged_settings)
assert merged_settings.get_agent_setting('llm.model') == 'gpt-4'
assert merged_mcp_config is not None
assert len(merged_mcp_config.sse_servers) == 2
# Config.toml server should come first (priority)
assert merged_settings.mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_settings.mcp_config.sse_servers[1].url == 'http://frontend-server.com'
assert merged_mcp_config.sse_servers[0].url == 'http://config-server.com'
assert merged_mcp_config.sse_servers[1].url == 'http://frontend-server.com'
@pytest.mark.asyncio
@@ -88,7 +100,7 @@ async def test_user_auth_caching_behavior():
# Verify both calls return the same merged settings
assert settings1 is settings2
assert len(settings1.mcp_config.sse_servers) == 2
assert len(_mcp_config(settings1).sse_servers) == 2
# Settings store should only be called once (first time)
mock_settings_store.load.assert_called_once()

View File

@@ -8,6 +8,7 @@ from pydantic import SecretStr
from openhands.integrations.provider import ProviderToken, ProviderType
from openhands.server.app import app
from openhands.server.routes import settings as settings_routes
from openhands.server.user_auth.user_auth import UserAuth
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.memory import InMemoryFileStore
@@ -17,7 +18,7 @@ from openhands.storage.settings.settings_store import SettingsStore
class MockUserAuth(UserAuth):
"""Mock implementation of UserAuth for testing"""
"""Mock implementation of UserAuth for testing."""
def __init__(self):
self._settings = None
@@ -64,7 +65,11 @@ class MockUserAuth(UserAuth):
def test_client():
# Create a test client
with (
patch.dict(os.environ, {'SESSION_API_KEY': ''}, clear=False),
patch.dict(
os.environ,
{'SESSION_API_KEY': '', 'ALLOW_SHORT_CONTEXT_WINDOWS': 'true'},
clear=False,
),
patch('openhands.server.dependencies._SESSION_API_KEY', None),
patch(
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
@@ -79,9 +84,100 @@ def test_client():
yield client
def test_get_agent_settings_schema_includes_verification_section():
schema = settings_routes._get_agent_settings_schema()
assert schema is not None
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.confirmation_mode' in field_keys
assert 'verification.security_analyzer' in field_keys
assert 'verification.critic_enabled' 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"""
"""Test that the settings API endpoints work with the new auth system."""
agent_settings_schema = {
'model_name': 'AgentSettings',
'sections': [
{
'key': 'llm',
'label': 'LLM',
'fields': [
{
'key': 'llm.model',
'value_type': 'string',
'prominence': 'critical',
},
{
'key': 'llm.base_url',
'value_type': 'string',
'prominence': 'major',
},
{
'key': 'llm.timeout',
'value_type': 'integer',
'prominence': 'minor',
},
{
'key': 'llm.litellm_extra_body',
'value_type': 'object',
'prominence': 'minor',
},
{
'key': 'llm.api_key',
'value_type': 'string',
'prominence': 'critical',
'secret': True,
},
],
},
{
'key': 'verification',
'label': 'Verification',
'fields': [
{
'key': 'verification.critic_enabled',
'value_type': 'boolean',
'prominence': 'critical',
},
{
'key': 'verification.critic_mode',
'value_type': 'string',
'prominence': 'minor',
},
{
'key': 'verification.enable_iterative_refinement',
'value_type': 'boolean',
'prominence': 'major',
},
{
'key': 'verification.critic_threshold',
'value_type': 'number',
'prominence': 'minor',
},
{
'key': 'verification.max_refinement_iterations',
'value_type': 'integer',
'prominence': 'minor',
},
{
'key': 'verification.confirmation_mode',
'value_type': 'boolean',
'prominence': 'major',
},
{
'key': 'verification.security_analyzer',
'value_type': 'string',
'prominence': 'major',
},
],
},
],
}
# Test data with remote_runtime_resource_factor
settings_data = {
'language': 'en',
@@ -89,44 +185,109 @@ async def test_settings_api_endpoints(test_client):
'max_iterations': 100,
'security_analyzer': 'default',
'confirmation_mode': True,
'llm_model': 'test-model',
'llm_api_key': 'test-key',
'llm_base_url': 'https://test.com',
'llm.model': 'test-model',
'llm.api_key': 'test-key',
'llm.base_url': 'https://test.com',
'llm.timeout': 123,
'llm.litellm_extra_body': {'metadata': {'tier': 'pro'}},
'remote_runtime_resource_factor': 2,
'verification.critic_enabled': True,
'verification.critic_mode': 'all_actions',
'verification.enable_iterative_refinement': True,
'verification.critic_threshold': 0.7,
'verification.max_refinement_iterations': 4,
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
}
# Make the POST request to store settings
with patch(
'openhands.server.routes.settings._get_agent_settings_schema',
return_value=agent_settings_schema,
):
# Make the POST request to store settings
response = test_client.post('/api/settings', json=settings_data)
# We're not checking the exact response, just that it doesn't error
assert response.status_code == 200
# Test the GET settings endpoint
response = test_client.get('/api/settings')
assert response.status_code == 200
response_data = response.json()
schema = response_data['agent_settings_schema']
assert schema['model_name'] == 'AgentSettings'
assert isinstance(schema['sections'], list)
assert [section['key'] for section in schema['sections']] == [
'llm',
'verification',
]
llm_section, verification_section = schema['sections']
assert llm_section['label'] == 'LLM'
assert [field['key'] for field in llm_section['fields']] == [
'llm.model',
'llm.base_url',
'llm.timeout',
'llm.litellm_extra_body',
'llm.api_key',
]
assert llm_section['fields'][-1]['secret'] is True
assert llm_section['fields'][2]['value_type'] == 'integer'
assert llm_section['fields'][3]['value_type'] == 'object'
assert verification_section['label'] == 'Verification'
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
assert vals['verification.confirmation_mode'] is True
assert vals['verification.security_analyzer'] == 'llm'
assert vals['llm.api_key'] == '<hidden>'
# Test updating with partial settings
partial_settings = {
'language': 'fr',
'llm_model': None, # Should preserve existing value
'llm_api_key': None, # Should preserve existing value
}
response = test_client.post('/api/settings', json=partial_settings)
assert response.status_code == 200
response = test_client.get('/api/settings')
assert response.status_code == 200
assert response.json()['agent_settings']['llm.timeout'] == 123
# Test the unset-provider-tokens endpoint
response = test_client.post('/api/unset-provider-tokens')
assert response.status_code == 200
@pytest.mark.asyncio
async def test_saving_settings_with_frozen_secrets_store(test_client):
"""Regression: POSTing settings must not fail when the payload includes
``secrets_store`` (a frozen field on the Settings model).
See https://github.com/OpenHands/OpenHands/issues/13306.
"""
settings_data = {
'language': 'en',
'llm.model': 'gpt-4',
'secrets_store': {'provider_tokens': {}},
}
response = test_client.post('/api/settings', json=settings_data)
# We're not checking the exact response, just that it doesn't error
assert response.status_code == 200
# Test the GET settings endpoint
response = test_client.get('/api/settings')
assert response.status_code == 200
# Test updating with partial settings
partial_settings = {
'language': 'fr',
'llm_model': None, # Should preserve existing value
'llm_api_key': None, # Should preserve existing value
}
response = test_client.post('/api/settings', json=partial_settings)
assert response.status_code == 200
# Test the unset-provider-tokens endpoint
response = test_client.post('/api/unset-provider-tokens')
assert response.status_code == 200
@pytest.mark.asyncio
async def test_search_api_key_preservation(test_client):
"""Test that search_api_key is preserved when sending empty string"""
# 1. Set initial settings with a search API key
"""Test that search_api_key is preserved when sending an empty string."""
# 1. Set initial settings with a search API key (use SDK dotted keys)
initial_settings = {
'search_api_key': 'initial-secret-key',
'llm_model': 'gpt-4',
'llm.model': 'gpt-4',
}
response = test_client.post('/api/settings', json=initial_settings)
assert response.status_code == 200
@@ -137,10 +298,10 @@ async def test_search_api_key_preservation(test_client):
assert response.json()['search_api_key_set'] is True
# 2. Update settings with EMPTY search API key (simulating the frontend bug)
# and changing another field (llm_model)
# and changing another field via SDK key
update_settings = {
'search_api_key': '', # The frontend sends an empty string here
'llm_model': 'claude-3-opus',
'search_api_key': '',
'llm.model': 'claude-3-opus',
}
response = test_client.post('/api/settings', json=update_settings)
assert response.status_code == 200
@@ -148,35 +309,34 @@ async def test_search_api_key_preservation(test_client):
# 3. Verify the key was NOT wiped out (The Critical Check)
response = test_client.get('/api/settings')
assert response.status_code == 200
# If the bug was present, this would be False
assert response.json()['search_api_key_set'] is True
# Verify the other field updated correctly
assert response.json()['llm_model'] == 'claude-3-opus'
# Verify the SDK value updated
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."""
# 1. Save settings with disabled_skills
settings_data = {
'llm_model': 'test-model',
'llm_api_key': 'test-key',
'disabled_skills': ['skill_a', 'skill_b'],
}
response = test_client.post('/api/settings', json=settings_data)
response = test_client.post(
'/api/settings',
json={
'disabled_skills': ['skill_a', 'skill_b'],
'llm.model': 'test-model',
},
)
assert response.status_code == 200
# 2. Retrieve and verify
response = test_client.get('/api/settings')
assert response.status_code == 200
data = response.json()
assert data['disabled_skills'] == ['skill_a', 'skill_b']
# 3. Update with a different list
update_settings = {
'disabled_skills': ['skill_c'],
}
response = test_client.post('/api/settings', json=update_settings)
response = test_client.post(
'/api/settings',
json={
'disabled_skills': ['skill_c'],
},
)
assert response.status_code == 200
response = test_client.get('/api/settings')
@@ -184,11 +344,12 @@ async def test_disabled_skills_persistence(test_client):
data = response.json()
assert data['disabled_skills'] == ['skill_c']
# 4. Clear the list
update_settings = {
'disabled_skills': [],
}
response = test_client.post('/api/settings', json=update_settings)
response = test_client.post(
'/api/settings',
json={
'disabled_skills': [],
},
)
assert response.status_code == 200
response = test_client.get('/api/settings')

View File

@@ -1,4 +1,5 @@
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -6,7 +7,6 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import SecretStr
from openhands.core.config.mcp_config import MCPConfig, MCPStdioServerConfig
from openhands.integrations.provider import ProviderToken
from openhands.integrations.service_types import ProviderType
from openhands.server.routes.secrets import (
@@ -15,13 +15,63 @@ from openhands.server.routes.secrets import (
from openhands.server.routes.secrets import (
check_provider_tokens,
)
from openhands.server.routes.settings import store_llm_settings
from openhands.server.routes.settings import _apply_settings_payload
from openhands.server.settings import POSTProviderModel
from openhands.storage import get_file_store
from openhands.storage.data_models.secrets import Secrets
from openhands.storage.data_models.settings import Settings
from openhands.storage.secrets.file_secrets_store import FileSecretsStore
# Minimal SDK schema fixture for tests.
_TEST_SDK_SCHEMA = {
'sections': [
{
'key': 'llm',
'fields': [
{'key': 'llm.model', 'secret': False},
{'key': 'llm.api_key', 'secret': True},
{'key': 'llm.base_url', 'secret': False},
],
},
{
'key': 'condenser',
'fields': [
{'key': 'condenser.enabled', 'secret': False},
{'key': 'condenser.max_size', 'secret': False},
],
},
{
'key': 'verification',
'fields': [
{'key': 'verification.confirmation_mode', 'secret': False},
{'key': 'verification.security_analyzer', 'secret': False},
],
},
]
}
def _make_settings(**sdk_vals: Any) -> Settings:
"""Helper to create Settings with agent_settings."""
if 'llm.api_key' in sdk_vals and 'llm.model' not in sdk_vals:
sdk_vals['llm.model'] = 'anthropic/claude-sonnet-4-5-20250929'
return Settings(agent_settings=sdk_vals)
@pytest.fixture(autouse=True)
def allow_short_context_windows():
with patch.dict(os.environ, {'ALLOW_SHORT_CONTEXT_WINDOWS': 'true'}, clear=False):
yield
def _agent_value(settings: Settings, key: str) -> Any:
return settings.get_agent_setting(key)
def _secret_value(settings: Settings, key: str) -> str | None:
secret = settings.get_secret_agent_setting(key)
return secret.get_secret_value() if secret else None
# Mock functions to simulate the actual functions in settings.py
async def get_settings_store(request):
@@ -113,16 +163,10 @@ async def test_check_provider_tokens_invalid():
@pytest.mark.asyncio
async def test_check_provider_tokens_wrong_type():
"""Test check_provider_tokens with unsupported provider type."""
# We can't test with an unsupported provider type directly since the model enforces valid types
# Instead, we'll test with an empty provider_tokens dictionary
providers = POSTProviderModel(provider_tokens={})
# Empty existing provider tokens
existing_provider_tokens = {}
result = await check_provider_tokens(providers, existing_provider_tokens)
# Should return empty string for no providers
assert result == ''
@@ -130,255 +174,241 @@ async def test_check_provider_tokens_wrong_type():
async def test_check_provider_tokens_no_tokens():
"""Test check_provider_tokens with no tokens."""
providers = POSTProviderModel(provider_tokens={})
# Empty existing provider tokens
existing_provider_tokens = {}
result = await check_provider_tokens(providers, existing_provider_tokens)
# Should return empty string when no tokens provided
assert result == ''
# Tests for store_llm_settings
@pytest.mark.asyncio
async def test_store_llm_settings_new_settings():
"""Test store_llm_settings with new settings."""
settings = Settings(
llm_model='gpt-4',
llm_api_key='test-api-key',
llm_base_url='https://api.example.com',
# Tests for _apply_settings_payload (SDK-first settings)
def test_apply_payload_sdk_keys_stored_and_readable():
"""SDK dotted keys should be stored in agent_settings and readable via properties."""
payload = {
'llm.model': 'gpt-4',
'llm.api_key': 'test-api-key',
'llm.base_url': 'https://api.example.com',
}
result = _apply_settings_payload(payload, None, _TEST_SDK_SCHEMA)
assert result.raw_agent_settings['llm.model'] == 'gpt-4'
assert result.raw_agent_settings['llm.api_key'] == 'test-api-key'
assert result.raw_agent_settings['llm.base_url'] == 'https://api.example.com'
# Properties read from agent_settings
assert _agent_value(result, 'llm.model') == 'gpt-4'
assert _secret_value(result, 'llm.api_key') == 'test-api-key'
assert _agent_value(result, 'llm.base_url') == 'https://api.example.com'
def test_apply_payload_updates_existing():
"""SDK keys should update existing settings."""
existing = _make_settings(
**{
'llm.model': 'gpt-3.5',
'llm.api_key': 'old-api-key',
'llm.base_url': 'https://old.example.com',
}
)
# No existing settings
existing_settings = None
payload = {
'llm.model': 'gpt-4',
'llm.api_key': 'new-api-key',
'llm.base_url': 'https://new.example.com',
}
result = await store_llm_settings(settings, existing_settings)
result = _apply_settings_payload(payload, existing, _TEST_SDK_SCHEMA)
# Should return settings with the provided values
assert result.llm_model == 'gpt-4'
assert result.llm_api_key.get_secret_value() == 'test-api-key'
assert result.llm_base_url == 'https://api.example.com'
assert _agent_value(result, 'llm.model') == 'gpt-4'
assert _secret_value(result, 'llm.api_key') == 'new-api-key'
assert _agent_value(result, 'llm.base_url') == 'https://new.example.com'
@pytest.mark.asyncio
async def test_store_llm_settings_update_existing():
"""Test store_llm_settings updates existing settings."""
settings = Settings(
llm_model='gpt-4',
llm_api_key='new-api-key',
llm_base_url='https://new.example.com',
def test_apply_payload_preserves_secrets_when_not_provided():
"""When the API key is not in the payload, the existing value is preserved."""
existing = _make_settings(
**{
'llm.model': 'gpt-3.5',
'llm.api_key': 'existing-api-key',
}
)
# Create existing settings
existing_settings = Settings(
llm_model='gpt-3.5',
llm_api_key=SecretStr('old-api-key'),
llm_base_url='https://old.example.com',
)
payload = {'llm.model': 'gpt-4'}
result = await store_llm_settings(settings, existing_settings)
result = _apply_settings_payload(payload, existing, _TEST_SDK_SCHEMA)
# Should return settings with the updated values
assert result.llm_model == 'gpt-4'
assert result.llm_api_key.get_secret_value() == 'new-api-key'
assert result.llm_base_url == 'https://new.example.com'
assert _agent_value(result, 'llm.model') == 'gpt-4'
assert _secret_value(result, 'llm.api_key') == 'existing-api-key'
assert _agent_value(result, 'llm.base_url') is None
@pytest.mark.asyncio
async def test_store_llm_settings_partial_update():
"""Test store_llm_settings with partial update.
Note: When llm_base_url is not provided in the update and the model is NOT an
openhands model, we attempt to get the URL from litellm.get_api_base().
For OpenAI models, this returns https://api.openai.com.
"""
settings = Settings(
llm_model='gpt-4', # Only updating model (not an openhands model)
llm_base_url='', # Explicitly cleared (e.g. basic mode save)
)
# Create existing settings
existing_settings = Settings(
llm_model='gpt-3.5',
llm_api_key=SecretStr('existing-api-key'),
llm_base_url='https://existing.example.com',
)
result = await store_llm_settings(settings, existing_settings)
# Should return settings with updated model but keep API key
assert result.llm_model == 'gpt-4'
# For SecretStr objects, we need to compare the secret value
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
# llm_base_url="" is an explicit clear — must not be repopulated via auto-detection
assert result.llm_base_url is None
@pytest.mark.asyncio
async def test_store_llm_settings_advanced_view_clear_removes_base_url():
"""Regression test for #13420: clearing Base URL in Advanced view must persist.
Before the fix, llm_base_url="" was treated identically to llm_base_url=None,
causing the backend to re-run auto-detection and overwrite the user's intent.
"""
settings = Settings(
llm_model='gpt-4',
llm_base_url='', # User deleted the field in Advanced view
)
existing_settings = Settings(
llm_model='gpt-4',
llm_api_key=SecretStr('my-api-key'),
llm_base_url='https://my-custom-proxy.example.com',
)
result = await store_llm_settings(settings, existing_settings)
# The old custom URL must not come back
assert result.llm_base_url is None
@pytest.mark.asyncio
async def test_store_llm_settings_mcp_update_preserves_base_url():
"""Test that saving MCP config (without LLM fields) preserves existing base URL.
Regression test: When adding an MCP server, the frontend sends only mcp_config
and v1_enabled. This should not wipe out the existing llm_base_url.
"""
# Simulate what the MCP add/update/delete mutations send: mcp_config but no LLM fields
settings = Settings(
mcp_config=MCPConfig(
stdio_servers=[
MCPStdioServerConfig(
name='my-server',
command='npx',
args=['-y', '@my/mcp-server'],
env={'API_TOKEN': 'secret123', 'ENDPOINT': 'https://example.com'},
)
],
),
)
# Create existing settings with a custom base URL
def test_apply_payload_mcp_update_preserves_existing_llm_settings():
existing_settings = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key=SecretStr('existing-api-key'),
llm_base_url='https://my-custom-proxy.example.com',
)
result = await store_llm_settings(settings, existing_settings)
# All existing LLM settings should be preserved
assert result.llm_model == 'anthropic/claude-sonnet-4-5-20250929'
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
assert result.llm_base_url == 'https://my-custom-proxy.example.com'
@pytest.mark.asyncio
async def test_store_llm_settings_no_existing_base_url_uses_auto_detection():
"""Test auto-detection kicks in only when there is no existing base URL.
When neither the incoming settings nor existing settings have a base URL,
auto-detection from litellm should be used.
"""
settings = Settings(
llm_model='gpt-4' # Not an openhands model
result = _apply_settings_payload(
{
'mcp_config': {
'stdio_servers': [
{
'name': 'my-server',
'command': 'npx',
'args': ['-y', '@my/mcp-server'],
'env': {
'API_TOKEN': 'secret123',
'ENDPOINT': 'https://example.com',
},
}
]
}
},
existing_settings,
_TEST_SDK_SCHEMA,
)
# Existing settings without a base URL
existing_settings = Settings(
llm_model='gpt-3.5',
llm_api_key=SecretStr('existing-api-key'),
assert _agent_value(result, 'llm.model') == 'anthropic/claude-sonnet-4-5-20250929'
assert _secret_value(result, 'llm.api_key') == 'existing-api-key'
assert _agent_value(result, 'llm.base_url') == 'https://my-custom-proxy.example.com'
def test_apply_payload_preserves_secrets_when_null():
"""Null/empty secret values in the payload should not overwrite existing secrets."""
existing = _make_settings(**{'llm.api_key': 'existing-api-key'})
payload = {'llm.api_key': None}
result = _apply_settings_payload(payload, existing, _TEST_SDK_SCHEMA)
assert result.raw_agent_settings['llm.api_key'] == 'existing-api-key'
payload = {'llm.api_key': ''}
result = _apply_settings_payload(payload, existing, _TEST_SDK_SCHEMA)
assert result.raw_agent_settings['llm.api_key'] == 'existing-api-key'
def test_apply_payload_mcp_preserves_llm_settings():
"""Non-LLM payloads (e.g. MCP config) should not affect existing LLM settings."""
existing = _make_settings(
**{
'llm.model': 'anthropic/claude-sonnet-4-5-20250929',
'llm.api_key': 'existing-api-key',
'llm.base_url': 'https://my-custom-proxy.example.com',
}
)
result = await store_llm_settings(settings, existing_settings)
payload = {
'mcp_config': {
'stdio_servers': [
{
'name': 'my-server',
'command': 'npx',
'args': ['-y', '@my/mcp-server'],
}
],
},
}
assert result.llm_model == 'gpt-4'
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
# No existing base URL, so auto-detection should set it
assert result.llm_base_url == 'https://api.openai.com'
result = _apply_settings_payload(payload, existing, _TEST_SDK_SCHEMA)
assert _agent_value(result, 'llm.model') == 'anthropic/claude-sonnet-4-5-20250929'
assert _secret_value(result, 'llm.api_key') == 'existing-api-key'
assert _agent_value(result, 'llm.base_url') == 'https://my-custom-proxy.example.com'
@pytest.mark.asyncio
async def test_store_llm_settings_anthropic_model_gets_api_base():
"""Test store_llm_settings with an Anthropic model.
def test_apply_payload_non_sdk_flat_keys_applied():
"""Non-SDK flat keys (language, git, etc.) should still be applied normally."""
payload = {
'language': 'ja',
'git_user_name': 'test-user',
}
For Anthropic models, get_provider_api_base() returns the Anthropic API base URL
via ProviderConfigManager.get_provider_model_info().
"""
settings = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929' # Anthropic model
result = _apply_settings_payload(payload, None, _TEST_SDK_SCHEMA)
assert result.language == 'ja'
assert result.git_user_name == 'test-user'
def test_apply_payload_verification_stored_and_readable():
"""Verification SDK keys are stored and readable via properties."""
payload = {
'verification.confirmation_mode': True,
'verification.security_analyzer': 'llm',
}
result = _apply_settings_payload(payload, None, _TEST_SDK_SCHEMA)
assert _agent_value(result, 'verification.confirmation_mode') is True
assert _agent_value(result, 'verification.security_analyzer') == 'llm'
assert result.raw_agent_settings['verification.confirmation_mode'] is True
def test_legacy_flat_fields_migrate_to_agent_vals():
"""Loading a Settings with legacy flat fields should migrate to agent_settings."""
s = Settings(
**{
'llm_model': 'gpt-4',
'llm_api_key': 'my-key',
'llm_base_url': 'https://example.com',
'agent': 'CodeActAgent',
'confirmation_mode': True,
}
)
existing_settings = Settings(
llm_model='gpt-3.5',
llm_api_key=SecretStr('existing-api-key'),
assert s.raw_agent_settings['llm.model'] == 'gpt-4'
assert s.raw_agent_settings['llm.api_key'] == 'my-key'
assert s.raw_agent_settings['llm.base_url'] == 'https://example.com'
assert s.raw_agent_settings['agent'] == 'CodeActAgent'
assert s.raw_agent_settings['verification.confirmation_mode'] is True
assert _agent_value(s, 'llm.model') == 'gpt-4'
assert _agent_value(s, 'agent') == 'CodeActAgent'
def test_agent_settings_normalized_with_schema_version_and_extras():
s = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
confirmation_mode=True,
agent_settings={'max_iterations': 64, 'custom.extra': 'keep-me'},
)
result = await store_llm_settings(settings, existing_settings)
assert result.llm_model == 'anthropic/claude-sonnet-4-5-20250929'
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
# Anthropic models get https://api.anthropic.com via ProviderConfigManager
assert result.llm_base_url == 'https://api.anthropic.com'
assert s.raw_agent_settings['schema_version'] == 1
assert s.raw_agent_settings['llm.model'] == 'anthropic/claude-sonnet-4-5-20250929'
assert s.raw_agent_settings['verification.confirmation_mode'] is True
assert s.raw_agent_settings['max_iterations'] == 64
assert s.raw_agent_settings['custom.extra'] == 'keep-me'
@pytest.mark.asyncio
async def test_store_llm_settings_litellm_error_logged():
"""Test that litellm errors are logged when getting api_base fails."""
from unittest.mock import patch
settings = Settings(
llm_model='unknown-model-xyz' # A model that litellm won't recognize
def test_agent_settings_persistence_strips_secret_values():
s = Settings(
llm_model='anthropic/claude-sonnet-4-5-20250929',
llm_api_key='super-secret',
agent_settings={'max_iterations': 64},
)
existing_settings = Settings(
llm_model='gpt-3.5',
llm_api_key=SecretStr('existing-api-key'),
persisted = s.normalized_agent_settings(strip_secret_values=True)
assert persisted['schema_version'] == 1
assert persisted['llm.model'] == 'anthropic/claude-sonnet-4-5-20250929'
assert persisted['max_iterations'] == 64
assert 'llm.api_key' not in persisted
def test_openhands_model_settings_remain_user_facing():
s = Settings(llm_model='openhands/claude-opus-4-5-20251101')
assert s.raw_agent_settings['llm.model'] == 'openhands/claude-opus-4-5-20251101'
assert s.normalized_agent_settings(strip_secret_values=True)['llm.model'] == (
'openhands/claude-opus-4-5-20251101'
)
# The function should not raise even if litellm fails
with patch('openhands.server.routes.settings.logger') as mock_logger:
result = await store_llm_settings(settings, existing_settings)
# llm_base_url should remain None since litellm couldn't find the model
assert result.llm_base_url is None
# Either error or debug should have been logged
assert mock_logger.error.called or mock_logger.debug.called
def test_litellm_proxy_model_settings_migrate_back_to_openhands_prefix():
s = Settings(agent_settings={'llm.model': 'litellm_proxy/claude-opus-4-5-20251101'})
@pytest.mark.asyncio
async def test_store_llm_settings_openhands_model_gets_default_url():
"""Test store_llm_settings with openhands model gets LiteLLM proxy URL.
When llm_base_url is not provided and the model is an openhands model,
it gets set to the default LiteLLM proxy URL.
"""
import os
settings = Settings(
llm_model='openhands/claude-sonnet-4-5-20250929' # openhands model
assert s.raw_agent_settings['llm.model'] == 'openhands/claude-opus-4-5-20251101'
assert s.normalized_agent_settings(strip_secret_values=True)['llm.model'] == (
'openhands/claude-opus-4-5-20251101'
)
# Create existing settings
existing_settings = Settings(
llm_model='gpt-3.5',
llm_api_key=SecretStr('existing-api-key'),
)
result = await store_llm_settings(settings, existing_settings)
# Should return settings with updated model
assert result.llm_model == 'openhands/claude-sonnet-4-5-20250929'
# For SecretStr objects, we need to compare the secret value
assert result.llm_api_key.get_secret_value() == 'existing-api-key'
# openhands models get the LiteLLM proxy URL
expected_base_url = os.environ.get(
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
)
assert result.llm_base_url == expected_base_url
# Tests for store_provider_tokens
@pytest.mark.asyncio

View File

@@ -273,6 +273,14 @@ class TestConversationInitDataValidator:
)
def test_conversation_init_data_default_agent_settings_initializes():
"""Frozen subclasses should still normalize default agent_settings."""
init_data = ConversationInitData()
assert init_data.raw_agent_settings == {'schema_version': 1}
assert init_data.agent_settings.schema_version == 1
def test_conversation_init_data_no_pydantic_frozen_field_warning():
"""Test that ConversationInitData model does not trigger Pydantic UnsupportedFieldAttributeWarning.

View File

@@ -4,10 +4,10 @@ from unittest.mock import patch
from pydantic import SecretStr
from openhands.core.config.llm_config import LLMConfig
from openhands.core.config.mcp_config import MCPConfig as LegacyMCPConfig
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.config.sandbox_config import SandboxConfig
from openhands.core.config.security_config import SecurityConfig
from openhands.server.routes.settings import convert_to_settings
from openhands.storage.data_models.settings import Settings
@@ -17,7 +17,7 @@ def test_settings_from_config():
default_agent='test-agent',
max_iterations=100,
security=SecurityConfig(
security_analyzer='test-analyzer',
security_analyzer='llm',
confirmation_mode=True,
),
llms={
@@ -38,13 +38,16 @@ def test_settings_from_config():
assert settings is not None
assert settings.language == 'en'
assert settings.agent == 'test-agent'
assert settings.max_iterations == 100
assert settings.security_analyzer == 'test-analyzer'
assert settings.confirmation_mode is True
assert settings.llm_model == 'test-model'
assert settings.llm_api_key.get_secret_value() == 'test-key'
assert settings.llm_base_url == 'https://test.example.com'
assert settings.get_agent_setting('agent') == 'test-agent'
assert settings.get_agent_setting('max_iterations') == 100
assert settings.get_agent_setting('verification.security_analyzer') == 'llm'
assert settings.get_agent_setting('verification.confirmation_mode') is True
assert settings.get_agent_setting('llm.model') == 'test-model'
assert (
settings.get_secret_agent_setting('llm.api_key').get_secret_value()
== 'test-key'
)
assert settings.get_agent_setting('llm.base_url') == 'https://test.example.com'
assert settings.remote_runtime_resource_factor == 2
assert not settings.secrets_store.provider_tokens
@@ -55,7 +58,7 @@ def test_settings_from_config_no_api_key():
default_agent='test-agent',
max_iterations=100,
security=SecurityConfig(
security_analyzer='test-analyzer',
security_analyzer='llm',
confirmation_mode=True,
),
llms={
@@ -79,7 +82,7 @@ def test_settings_handles_sensitive_data():
language='en',
agent='test-agent',
max_iterations=100,
security_analyzer='test-analyzer',
security_analyzer='llm',
confirmation_mode=True,
llm_model='test-model',
llm_api_key='test-key',
@@ -87,18 +90,112 @@ def test_settings_handles_sensitive_data():
remote_runtime_resource_factor=2,
)
assert str(settings.llm_api_key) == '**********'
assert settings.llm_api_key.get_secret_value() == 'test-key'
llm_api_key = settings.get_secret_agent_setting('llm.api_key')
assert str(llm_api_key) == '**********'
assert llm_api_key.get_secret_value() == 'test-key'
def test_convert_to_settings():
settings_with_token_data = Settings(
llm_api_key='test-key',
def test_settings_preserve_agent_settings():
settings = Settings(
agent_settings={
'llm.model': 'test-model',
'llm.api_key': 'test-key',
'verification.critic_enabled': True,
'verification.critic_mode': 'all_actions',
'llm.litellm_extra_body': {'metadata': {'tier': 'pro'}},
},
)
settings = convert_to_settings(settings_with_token_data)
assert (
settings.get_secret_agent_setting('llm.api_key').get_secret_value()
== 'test-key'
)
assert settings.raw_agent_settings == {
'schema_version': 1,
'llm.model': 'test-model',
'llm.api_key': 'test-key',
'verification.critic_enabled': True,
'verification.critic_mode': 'all_actions',
'llm.litellm_extra_body': {'metadata': {'tier': 'pro'}},
}
assert settings.llm_api_key.get_secret_value() == 'test-key'
def test_settings_to_agent_settings_uses_agent_vals():
settings = Settings(
agent_settings={
'llm.model': 'sdk-model',
'llm.base_url': 'https://sdk.example.com',
'llm.litellm_extra_body': {'metadata': {'tier': 'enterprise'}},
'condenser.enabled': False,
'condenser.max_size': 88,
'verification.critic_enabled': True,
'verification.critic_mode': 'all_actions',
},
)
agent_settings = settings.to_agent_settings()
assert agent_settings.llm.model == 'sdk-model'
assert agent_settings.llm.base_url == 'https://sdk.example.com'
assert agent_settings.llm.litellm_extra_body == {'metadata': {'tier': 'enterprise'}}
assert agent_settings.condenser.enabled is False
assert agent_settings.condenser.max_size == 88
assert agent_settings.verification.critic_enabled is True
assert agent_settings.verification.critic_mode == 'all_actions'
def test_settings_agent_settings_keeps_sdk_mcp_shape_canonical():
settings = Settings(
agent_settings={
'llm.model': 'sdk-model',
'mcp_config': {
'sse_servers': [{'url': 'https://example.com/sse'}],
},
},
)
assert settings.raw_agent_settings['mcp_config'] == {
'mcpServers': {'sse_0': {'transport': 'sse', 'url': 'https://example.com/sse'}}
}
assert settings.agent_settings_values()['mcp_config'] == {
'mcpServers': {'sse_0': {'transport': 'sse', 'url': 'https://example.com/sse'}}
}
assert settings.to_legacy_mcp_config() == LegacyMCPConfig.model_validate(
{'sse_servers': [{'url': 'https://example.com/sse'}]}
)
def test_settings_set_agent_setting_keeps_sdk_mcp_shape_for_persistence():
settings = Settings(agent_settings={'llm.model': 'sdk-model'})
settings.set_agent_setting(
'mcp_config',
{
'mcpServers': {
'custom': {'transport': 'http', 'url': 'https://example.com/mcp'}
}
},
)
assert settings.raw_agent_settings['mcp_config'] == {
'mcpServers': {
'custom': {
'transport': 'http',
'url': 'https://example.com/mcp',
}
}
}
assert settings.agent_settings_values()['mcp_config'] == {
'mcpServers': {
'custom': {
'transport': 'http',
'url': 'https://example.com/mcp',
}
}
}
assert settings.to_legacy_mcp_config() == LegacyMCPConfig.model_validate(
{'shttp_servers': [{'url': 'https://example.com/mcp', 'timeout': 60}]}
)
def test_settings_no_pydantic_frozen_field_warning():

View File

@@ -1,3 +1,4 @@
import os
from unittest.mock import MagicMock, patch
import pytest
@@ -13,6 +14,12 @@ def mock_file_store():
return MagicMock(spec=FileStore)
@pytest.fixture(autouse=True)
def allow_short_context_windows():
with patch.dict(os.environ, {'ALLOW_SHORT_CONTEXT_WINDOWS': 'true'}, clear=False):
yield
@pytest.fixture
def file_settings_store(mock_file_store):
return FileSettingsStore(mock_file_store)
@@ -35,7 +42,7 @@ async def test_store_and_load_data(file_settings_store):
language='python',
agent='test-agent',
max_iterations=100,
security_analyzer='default',
security_analyzer='llm',
confirmation_mode=True,
llm_model='test-model',
llm_api_key='test-key',
@@ -58,18 +65,30 @@ async def test_store_and_load_data(file_settings_store):
loaded_data = await file_settings_store.load()
assert loaded_data is not None
assert loaded_data.language == init_data.language
assert loaded_data.agent == init_data.agent
assert loaded_data.max_iterations == init_data.max_iterations
assert loaded_data.security_analyzer == init_data.security_analyzer
assert loaded_data.confirmation_mode == init_data.confirmation_mode
assert loaded_data.llm_model == init_data.llm_model
assert loaded_data.llm_api_key
assert init_data.llm_api_key
assert (
loaded_data.llm_api_key.get_secret_value()
== init_data.llm_api_key.get_secret_value()
assert loaded_data.get_agent_setting('agent') == init_data.get_agent_setting(
'agent'
)
assert loaded_data.get_agent_setting(
'max_iterations'
) == init_data.get_agent_setting('max_iterations')
assert loaded_data.get_agent_setting(
'verification.security_analyzer'
) == init_data.get_agent_setting('verification.security_analyzer')
assert loaded_data.get_agent_setting(
'verification.confirmation_mode'
) == init_data.get_agent_setting('verification.confirmation_mode')
assert loaded_data.get_agent_setting('llm.model') == init_data.get_agent_setting(
'llm.model'
)
assert loaded_data.get_secret_agent_setting('llm.api_key')
assert init_data.get_secret_agent_setting('llm.api_key')
assert (
loaded_data.get_secret_agent_setting('llm.api_key').get_secret_value()
== init_data.get_secret_agent_setting('llm.api_key').get_secret_value()
)
assert loaded_data.get_agent_setting('llm.base_url') == init_data.get_agent_setting(
'llm.base_url'
)
assert loaded_data.llm_base_url == init_data.llm_base_url
@pytest.mark.asyncio

24
uv.lock generated
View File

@@ -3643,7 +3643,7 @@ wheels = [
[[package]]
name = "openhands-agent-server"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/OpenHands/software-agent-sdk.git?subdirectory=openhands-agent-server&rev=openhands%2Fissue-2228-sdk-settings-schema#e5275bbcd930c62f6dbb1b567eb0d08ead4dbd28" }
dependencies = [
{ name = "aiosqlite" },
{ name = "alembic" },
@@ -3656,10 +3656,6 @@ dependencies = [
{ name = "websockets" },
{ name = "wsproto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c1/9f/66c45457f7510876bdf337feeef53c1fc6a2521be5ec707b63bcc76810d7/openhands_agent_server-1.14.0.tar.gz", hash = "sha256:396de8d878c0a6c1c23d830f7407e34801ac850f4283ba296d7fe436d8b61488", size = 75545, upload-time = "2026-03-13T21:19:09.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/df/55a79fa605b2dcf6cc524525cefa5d04cf0083649a77f4130c6b14e9b153/openhands_agent_server-1.14.0-py3-none-any.whl", hash = "sha256:b1374b50d0ce93d825ba5ea907fcb8840b5ddc594c6752570c7c4c27be1a9fd1", size = 90634, upload-time = "2026-03-13T21:19:15.859Z" },
]
[[package]]
name = "openhands-ai"
@@ -3827,9 +3823,9 @@ requires-dist = [
{ name = "numpy" },
{ name = "openai", specifier = "==2.8" },
{ name = "openhands-aci", specifier = "==0.3.3" },
{ name = "openhands-agent-server", specifier = "==1.14" },
{ name = "openhands-sdk", specifier = "==1.14" },
{ name = "openhands-tools", specifier = "==1.14" },
{ name = "openhands-agent-server", git = "https://github.com/OpenHands/software-agent-sdk.git?subdirectory=openhands-agent-server&rev=openhands%2Fissue-2228-sdk-settings-schema" },
{ name = "openhands-sdk", git = "https://github.com/OpenHands/software-agent-sdk.git?subdirectory=openhands-sdk&rev=openhands%2Fissue-2228-sdk-settings-schema" },
{ name = "openhands-tools", git = "https://github.com/OpenHands/software-agent-sdk.git?subdirectory=openhands-tools&rev=openhands%2Fissue-2228-sdk-settings-schema" },
{ name = "opentelemetry-api", specifier = ">=1.33.1" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.33.1" },
{ name = "orjson", specifier = ">=3.11.6" },
@@ -3909,7 +3905,7 @@ test = [
[[package]]
name = "openhands-sdk"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/OpenHands/software-agent-sdk.git?subdirectory=openhands-sdk&rev=openhands%2Fissue-2228-sdk-settings-schema#e5275bbcd930c62f6dbb1b567eb0d08ead4dbd28" }
dependencies = [
{ name = "agent-client-protocol" },
{ name = "deprecation" },
@@ -3925,15 +3921,11 @@ dependencies = [
{ name = "tenacity" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e7/59/52aa47a54243132d57e29920ce38fbc08fee71532d4ea91647916c441859/openhands_sdk-1.14.0.tar.gz", hash = "sha256:30bda4b10291420f753d14aaa4ee67c87ba8d59ef3908bca999aa76daa033615", size = 332289, upload-time = "2026-03-13T21:19:13.81Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/27/bab7af3fb67bdfa1f7278c23713443775940800687b83dd33a9e46f8653a/openhands_sdk-1.14.0-py3-none-any.whl", hash = "sha256:64305b3a24445fd9480b63129e8e02f3a75fdbf8f4fcbf970760b7dc1d392090", size = 422447, upload-time = "2026-03-13T21:19:10.414Z" },
]
[[package]]
name = "openhands-tools"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
source = { git = "https://github.com/OpenHands/software-agent-sdk.git?subdirectory=openhands-tools&rev=openhands%2Fissue-2228-sdk-settings-schema#e5275bbcd930c62f6dbb1b567eb0d08ead4dbd28" }
dependencies = [
{ name = "bashlex" },
{ name = "binaryornot" },
@@ -3945,10 +3937,6 @@ dependencies = [
{ name = "pydantic" },
{ name = "tom-swe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/55/5201e34dae494bb30763ab920728583c14dff80a54bcf0e279ae46ab32ad/openhands_tools-1.14.0.tar.gz", hash = "sha256:2655a7de839b171539464fa39729b6a338dc37f914b58bd551378c4fc0ec71b5", size = 112223, upload-time = "2026-03-13T21:19:15.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/37/6872132cb83cd80c7bf85deb6ff8a3c9e0e37ae89e0724d1119209d82601/openhands_tools-1.14.0-py3-none-any.whl", hash = "sha256:4df477fa53eafa15082d081143c80383aeb6d52b4448b989b86b811c297e5615", size = 152507, upload-time = "2026-03-13T21:19:11.855Z" },
]
[[package]]
name = "openpyxl"