mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
74 Commits
draft/remo
...
pr-13306
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a52e5bdc26 | ||
|
|
e9b0f7b5c1 | ||
|
|
36ff8bcb9c | ||
|
|
8d9841e95a | ||
|
|
0ca9528afe | ||
|
|
78f877aca5 | ||
|
|
9e6f5bae9c | ||
|
|
b104c35075 | ||
|
|
c1958bef4d | ||
|
|
80dc1b9a38 | ||
|
|
3df73ea5eb | ||
|
|
1f88750ade | ||
|
|
c3341e3a0e | ||
|
|
14a234cdbe | ||
|
|
806da849c5 | ||
|
|
5a47e52176 | ||
|
|
cdf0ac8421 | ||
|
|
1dd4d0fc9d | ||
|
|
c07a85aec0 | ||
|
|
13f244e6e7 | ||
|
|
5477028bc8 | ||
|
|
e930a51f05 | ||
|
|
cf877b5628 | ||
|
|
fb9958aff8 | ||
|
|
c1f5861eaf | ||
|
|
fa7f58b7c5 | ||
|
|
a691bec7fc | ||
|
|
7eb77c131d | ||
|
|
858870a095 | ||
|
|
d65e5b5e46 | ||
|
|
2b0816f53a | ||
|
|
ab9536dc6b | ||
|
|
9f5888315a | ||
|
|
fcefb872b6 | ||
|
|
b91cd0570e | ||
|
|
e18775b391 | ||
|
|
495de35139 | ||
|
|
be29d89b3c | ||
|
|
2890b8c6ff | ||
|
|
39a846ccc3 | ||
|
|
cae7b6e72f | ||
|
|
7ca41486be | ||
|
|
81c02623a1 | ||
|
|
38dcf959bc | ||
|
|
ef3acf726c | ||
|
|
017d758a76 | ||
|
|
3ed37e18ac | ||
|
|
1322f944be | ||
|
|
5925483f6b | ||
|
|
0144424c8e | ||
|
|
f07ce85b45 | ||
|
|
bc5a46dcee | ||
|
|
9990870060 | ||
|
|
bab9a45590 | ||
|
|
b4107ff9dc | ||
|
|
3e04713097 | ||
|
|
77f868081c | ||
|
|
3a12924bc8 | ||
|
|
cfa7def554 | ||
|
|
33d6a11abf | ||
|
|
71d5aa5aa8 | ||
|
|
90d2681e34 | ||
|
|
565a5702c3 | ||
|
|
4b9097068d | ||
|
|
c9a5834164 | ||
|
|
19a089aa4b | ||
|
|
918c44d164 | ||
|
|
e06e20a5ba | ||
|
|
430ee1c9fd | ||
|
|
a03377698c | ||
|
|
9dab5b1bbf | ||
|
|
135d5fbd38 | ||
|
|
ad615ebc8b | ||
|
|
424f6b30d1 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
3721
enterprise/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
|
||||
38
enterprise/storage/agent_settings_utils.py
Normal file
38
enterprise/storage/agent_settings_utils.py
Normal 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 {}))
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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 {}))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-schema–driven 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "マージリクエスト",
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
15
frontend/src/routes/condenser-settings.tsx
Normal file
15
frontend/src/routes/condenser-settings.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
15
frontend/src/routes/verification-settings.tsx
Normal file
15
frontend/src/routes/verification-settings.tsx
Normal 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;
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
216
frontend/src/utils/sdk-settings-schema.test.ts
Normal file
216
frontend/src/utils/sdk-settings-schema.test.ts
Normal 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" } },
|
||||
});
|
||||
});
|
||||
});
|
||||
344
frontend/src/utils/sdk-settings-schema.ts
Normal file
344
frontend/src/utils/sdk-settings-schema.ts
Normal 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");
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
24
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user