Compare commits

..

1 Commits

Author SHA1 Message Date
mamoodi
d063c8ca92 Release 1.3 2026-02-02 11:05:29 -05:00
38 changed files with 1952 additions and 2121 deletions

26
enterprise/poetry.lock generated
View File

@@ -6102,14 +6102,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.11.1"
version = "1.10.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.11.1-py3-none-any.whl", hash = "sha256:28e3ca670114c7a936a33f2d193238fbdc75f429c4e0bb99a03b14e6c01663c9"},
{file = "openhands_agent_server-1.11.1.tar.gz", hash = "sha256:06eaf8b8eda4ca05de24751a7d269b22f611328c6cb2b4b91f2486011228b69a"},
{file = "openhands_agent_server-1.10.0-py3-none-any.whl", hash = "sha256:2e21076fff5e7cf9d03a3b011e2c90a6a3a46d2da3f18db9f7553ac413229c22"},
{file = "openhands_agent_server-1.10.0.tar.gz", hash = "sha256:2062da2496a98a6c23201d086f124e02329d6c6d9d1b47be55921c084a29f55a"},
]
[package.dependencies]
@@ -6126,7 +6126,7 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-ai"
version = "1.3.0"
version = "1.2.1"
description = "OpenHands: Code Less, Make More"
optional = false
python-versions = "^3.12,<3.14"
@@ -6168,9 +6168,9 @@ memory-profiler = ">=0.61"
numpy = "*"
openai = "2.8"
openhands-aci = "0.3.2"
openhands-agent-server = "1.11.1"
openhands-sdk = "1.11.1"
openhands-tools = "1.11.1"
openhands-agent-server = "1.10"
openhands-sdk = "1.10"
openhands-tools = "1.10"
opentelemetry-api = ">=1.33.1"
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
pathspec = ">=0.12.1"
@@ -6225,14 +6225,14 @@ url = ".."
[[package]]
name = "openhands-sdk"
version = "1.11.1"
version = "1.10.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.11.1-py3-none-any.whl", hash = "sha256:10ee0777286b149db21bdeeadb6d4c57f461da4049a4ba07576e7228b5c76c85"},
{file = "openhands_sdk-1.11.1.tar.gz", hash = "sha256:57f5884d0596a8659b7c0cdbe86ebaa74c810c4e2645fcff45f0113894dd9376"},
{file = "openhands_sdk-1.10.0-py3-none-any.whl", hash = "sha256:5c8875f2a07d7fabe3449914639572bef9003821207cb06aa237a239e964eed5"},
{file = "openhands_sdk-1.10.0.tar.gz", hash = "sha256:93371b1af4532266ad2d225b9d7d3d711c745df31888efe643970673f62bdef9"},
]
[package.dependencies]
@@ -6253,14 +6253,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.11.1"
version = "1.10.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.11.1-py3-none-any.whl", hash = "sha256:0b64763def90dda5b6545a356a437437c2029ec9bc47a4e6dac5c06dea6a4e77"},
{file = "openhands_tools-1.11.1.tar.gz", hash = "sha256:2a71d2d0619ca631b3b7f5bd741bfdf97f7ebe6f96dc2540f79b9a688a6309fc"},
{file = "openhands_tools-1.10.0-py3-none-any.whl", hash = "sha256:1d5d2d1e34cc4ceb02c0ff1f008b06883ad48a8e7236ab8dd61ece64fbf8e2ed"},
{file = "openhands_tools-1.10.0.tar.gz", hash = "sha256:7ed38cb13545ec2c4a35c26ece725d5b35788d30597db8b1904619c043ec1194"},
]
[package.dependencies]

View File

@@ -18,7 +18,6 @@ from openhands.core.logger import openhands_logger as logger
class AssessmentResult:
"""Result of a reCAPTCHA Enterprise assessment."""
name: str
score: float
valid: bool
action_valid: bool
@@ -64,7 +63,6 @@ class RecaptchaService:
user_ip: str,
user_agent: str,
email: str | None = None,
user_id: str | None = None,
) -> AssessmentResult:
"""Create a reCAPTCHA Enterprise assessment.
@@ -74,7 +72,6 @@ class RecaptchaService:
user_ip: The user's IP address.
user_agent: The user's browser user agent.
email: Optional email for Account Defender hashing.
user_id: Optional Keycloak user ID for logging correlation.
Returns:
AssessmentResult with score, validity, and allowed status.
@@ -103,10 +100,6 @@ class RecaptchaService:
response = self.client.create_assessment(request)
# Capture assessment name for potential annotation later
# Format: projects/{project_id}/assessments/{assessment_id}
assessment_name = response.name
token_properties = response.token_properties
risk_analysis = response.risk_analysis
@@ -136,7 +129,6 @@ class RecaptchaService:
logger.info(
'recaptcha_assessment',
extra={
'assessment_name': assessment_name,
'score': score,
'valid': valid,
'action_valid': action_valid,
@@ -145,13 +137,10 @@ class RecaptchaService:
'has_suspicious_labels': has_suspicious_labels,
'allowed': allowed,
'user_ip': user_ip,
'user_id': user_id,
'email': email,
},
)
return AssessmentResult(
name=assessment_name,
score=score,
valid=valid,
action_valid=action_valid,

View File

@@ -179,9 +179,6 @@ async def keycloak_callback(
user = await UserStore.get_user_by_id_async(user_id)
if not user:
user = await UserStore.create_user(user_id, user_info)
else:
# Existing user — gradually backfill contact_name if it still has a username-style value
await UserStore.backfill_contact_name(user_id, user_info)
if not user:
logger.error(f'Failed to authenticate user {user_info["preferred_username"]}')
@@ -222,7 +219,6 @@ async def keycloak_callback(
user_ip=user_ip,
user_agent=user_agent,
email=email,
user_id=user_id,
)
if not result.allowed:

View File

@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import JSONResponse
from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from utils.identity import resolve_display_name
from openhands.integrations.provider import (
PROVIDER_TOKEN_TYPE,
@@ -122,8 +121,6 @@ async def saas_get_user(
login=(user_info.get('preferred_username') if user_info else '') or '',
avatar_url='',
email=user_info.get('email') if user_info else None,
name=resolve_display_name(user_info) if user_info else None,
company=user_info.get('company') if user_info else None,
),
user_info=user_info,
)

View File

@@ -24,23 +24,13 @@ from storage.user_settings import UserSettings
from openhands.server.settings import Settings
from openhands.utils.http_session import httpx_verify_option
# Timeout in seconds for key verification requests to LiteLLM
KEY_VERIFICATION_TIMEOUT = 5.0
# Timeout in seconds for BYOR key verification requests to LiteLLM
BYOR_KEY_VERIFICATION_TIMEOUT = 5.0
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
UNLIMITED_BUDGET_SETTING = 1000000000.0
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
"""Generate the key alias for OpenHands Cloud managed keys."""
return f'OpenHands Cloud - user {keycloak_user_id} - org {org_id}'
def get_byor_key_alias(keycloak_user_id: str, org_id: str) -> str:
"""Generate the key alias for BYOR (Bring Your Own Runtime) keys."""
return f'BYOR Key - user {keycloak_user_id}, org {org_id}'
class LiteLlmManager:
"""Manage LiteLLM interactions."""
@@ -89,7 +79,7 @@ class LiteLlmManager:
client,
keycloak_user_id,
org_id,
get_openhands_cloud_key_alias(keycloak_user_id, org_id),
f'OpenHands Cloud - user {keycloak_user_id} - org {org_id}',
None,
)
@@ -261,7 +251,7 @@ class LiteLlmManager:
client,
keycloak_user_id,
org_id,
get_openhands_cloud_key_alias(keycloak_user_id, org_id),
f'OpenHands Cloud - user {keycloak_user_id} - org {org_id}',
None,
)
if new_key:
@@ -1054,7 +1044,7 @@ class LiteLlmManager:
try:
async with httpx.AsyncClient(
verify=httpx_verify_option(),
timeout=KEY_VERIFICATION_TIMEOUT,
timeout=BYOR_KEY_VERIFICATION_TIMEOUT,
) as client:
# Make a lightweight request to verify the key
# Using /v1/models endpoint as it's lightweight and requires authentication
@@ -1068,7 +1058,7 @@ class LiteLlmManager:
# Only 200 status code indicates valid key
if response.status_code == 200:
logger.debug(
'Key verification successful',
'BYOR key verification successful',
extra={'user_id': user_id},
)
return True
@@ -1076,7 +1066,7 @@ class LiteLlmManager:
# All other status codes (401, 403, 500, etc.) are treated as invalid
# This includes authentication errors and server errors
logger.warning(
'Key verification failed - treating as invalid',
'BYOR key verification failed - treating as invalid',
extra={
'user_id': user_id,
'status_code': response.status_code,
@@ -1089,7 +1079,7 @@ class LiteLlmManager:
# Any exception (timeout, network error, etc.) means we can't verify
# Return False to trigger regeneration rather than returning potentially invalid key
logger.warning(
'Key verification error - treating as invalid to ensure key validity',
'BYOR key verification error - treating as invalid to ensure key validity',
extra={
'user_id': user_id,
'error': str(e),
@@ -1133,103 +1123,6 @@ class LiteLlmManager:
'key_spend': key_info.get('spend'),
}
@staticmethod
async def _get_all_keys_for_user(
client: httpx.AsyncClient,
keycloak_user_id: str,
) -> list[dict]:
"""Get all keys for a user from LiteLLM.
Returns a list of key info dictionaries containing:
- token: the key value (hashed or partial)
- key_alias: the alias for the key
- key_name: the name of the key
- spend: the amount spent on this key
- max_budget: the max budget for this key
- team_id: the team the key belongs to
- metadata: any metadata associated with the key
Returns an empty list if no keys found or on error.
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return []
try:
response = await client.get(
f'{LITE_LLM_API_URL}/user/info?user_id={keycloak_user_id}',
headers={'x-goog-api-key': LITE_LLM_API_KEY},
)
response.raise_for_status()
user_json = response.json()
# The user/info endpoint returns keys in the 'keys' field
return user_json.get('keys', [])
except Exception as e:
logger.warning(
'LiteLlmManager:_get_all_keys_for_user:error',
extra={
'user_id': keycloak_user_id,
'error': str(e),
},
)
return []
@staticmethod
async def _verify_existing_key(
client: httpx.AsyncClient,
key_value: str,
keycloak_user_id: str,
org_id: str,
openhands_type: bool = False,
) -> bool:
"""Check if an existing key exists for the user/org in LiteLLM.
Verifies the provided key_value matches a key registered in LiteLLM for
the given user and organization. For openhands_type=True, looks for keys
with metadata type='openhands' and matching team_id. For openhands_type=False,
looks for keys with matching alias and team_id.
Returns True if the key is found and valid, False otherwise.
"""
found = False
keys = await LiteLlmManager._get_all_keys_for_user(client, keycloak_user_id)
for key_info in keys:
metadata = key_info.get('metadata') or {}
team_id = key_info.get('team_id')
key_alias = key_info.get('key_alias')
token = None
if (
openhands_type
and metadata.get('type') == 'openhands'
and team_id == org_id
):
# Found an existing OpenHands key for this org
key_name = key_info.get('key_name')
token = key_name[-4:] if key_name else None # last 4 digits of key
if token and key_value.endswith(
token
): # check if this is our current key
found = True
break
if (
not openhands_type
and team_id == org_id
and (
key_alias == get_openhands_cloud_key_alias(keycloak_user_id, org_id)
or key_alias == get_byor_key_alias(keycloak_user_id, org_id)
)
):
# Found an existing key for this org (regardless of type)
key_name = key_info.get('key_name')
token = key_name[-4:] if key_name else None # last 4 digits of key
if token and key_value.endswith(
token
): # check if this is our current key
found = True
break
return found
@staticmethod
async def _delete_key_by_alias(
client: httpx.AsyncClient,
@@ -1327,8 +1220,6 @@ class LiteLlmManager:
update_user_in_team = staticmethod(with_http_client(_update_user_in_team))
generate_key = staticmethod(with_http_client(_generate_key))
get_key_info = staticmethod(with_http_client(_get_key_info))
verify_existing_key = staticmethod(with_http_client(_verify_existing_key))
delete_key = staticmethod(with_http_client(_delete_key))
get_user_keys = staticmethod(with_http_client(_get_user_keys))
delete_key_by_alias = staticmethod(with_http_client(_delete_key_by_alias))
update_user_keys = staticmethod(with_http_client(_update_user_keys))

View File

@@ -5,8 +5,7 @@ Store class for managing organization-member relationships.
from typing import Optional
from uuid import UUID
from sqlalchemy import select
from storage.database import a_session_maker, session_maker
from storage.database import session_maker
from storage.org_member import OrgMember
from storage.user_settings import UserSettings
@@ -39,7 +38,7 @@ class OrgMemberStore:
return org_member
@staticmethod
def get_org_member(org_id: UUID, user_id: UUID) -> Optional[OrgMember]:
def get_org_member(org_id: UUID, user_id: int) -> Optional[OrgMember]:
"""Get organization-user relationship."""
with session_maker() as session:
return (
@@ -49,18 +48,7 @@ class OrgMemberStore:
)
@staticmethod
async def get_org_member_async(org_id: UUID, user_id: UUID) -> Optional[OrgMember]:
"""Get organization-user relationship."""
async with a_session_maker() as session:
result = await session.execute(
select(OrgMember).filter(
OrgMember.org_id == org_id, OrgMember.user_id == user_id
)
)
return result.scalars().first()
@staticmethod
def get_user_orgs(user_id: UUID) -> list[OrgMember]:
def get_user_orgs(user_id: int) -> list[OrgMember]:
"""Get all organizations for a user."""
with session_maker() as session:
return session.query(OrgMember).filter(OrgMember.user_id == user_id).all()
@@ -80,7 +68,7 @@ class OrgMemberStore:
@staticmethod
def update_user_role_in_org(
org_id: UUID, user_id: UUID, role_id: int, status: Optional[str] = None
org_id: UUID, user_id: int, role_id: int, status: Optional[str] = None
) -> Optional[OrgMember]:
"""Update user's role in an organization."""
with session_maker() as session:
@@ -102,7 +90,7 @@ class OrgMemberStore:
return org_member
@staticmethod
def remove_user_from_org(org_id: UUID, user_id: UUID) -> bool:
def remove_user_from_org(org_id: UUID, user_id: int) -> bool:
"""Remove a user from an organization."""
with session_maker() as session:
org_member = (

View File

@@ -8,11 +8,10 @@ from dataclasses import dataclass
from cryptography.fernet import Fernet
from pydantic import SecretStr
from server.constants import LITE_LLM_API_URL
from server.logger import logger
from sqlalchemy.orm import joinedload, sessionmaker
from storage.database import session_maker
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
from storage.lite_llm_manager import LiteLlmManager
from storage.org import Org
from storage.org_member import OrgMember
from storage.org_store import OrgStore
@@ -106,13 +105,13 @@ class SaasSettingsStore(SettingsStore):
},
}
kwargs['llm_api_key'] = org_member.llm_api_key
if org_member.max_iterations is not None:
if org_member.max_iterations:
kwargs['max_iterations'] = org_member.max_iterations
if org_member.llm_model is not None:
if org_member.llm_model:
kwargs['llm_model'] = org_member.llm_model
if org_member.llm_api_key_for_byor is not None:
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 is not None:
if org_member.llm_base_url:
kwargs['llm_base_url'] = org_member.llm_base_url
if org.v1_enabled is None:
kwargs['v1_enabled'] = True
@@ -144,27 +143,23 @@ class SaasSettingsStore(SettingsStore):
return None
org_id = user.current_org_id
org_member: OrgMember = None
# Check if provider is OpenHands and generate API key if needed
if self._is_openhands_provider(item):
await self._ensure_openhands_api_key(item, str(org_id))
org_member = None
for om in user.org_members:
if om.org_id == org_id:
org_member = om
break
if not org_member or not org_member.llm_api_key:
return None
org: Org = session.query(Org).filter(Org.id == org_id).first()
org = session.query(Org).filter(Org.id == org_id).first()
if not org:
logger.error(
f'Org not found for ID {org_id} as the current org for user {self.user_id}'
)
return None
# Check if we need to generate an LLM key.
is_openhands_provider = self._is_openhands_provider(item)
if is_openhands_provider or item.llm_base_url == LITE_LLM_API_URL:
await self._ensure_api_key(item, str(org_id), is_openhands_provider)
kwargs = item.model_dump(context={'expose_secrets': True})
for model in (user, org, org_member):
for key, value in kwargs.items():
@@ -232,49 +227,37 @@ class SaasSettingsStore(SettingsStore):
"""Check if the settings use the OpenHands provider."""
return bool(item.llm_model and item.llm_model.startswith('openhands/'))
async def _ensure_api_key(
self, item: Settings, org_id: str, openhands_type: bool = False
) -> None:
async def _ensure_openhands_api_key(self, item: Settings, org_id: str) -> None:
"""Generate and set the OpenHands API key for the given settings.
First checks if an existing key exists for the user and verifies it
is valid in LiteLLM. If valid, reuses it. Otherwise, generates a new key.
First checks if an existing key exists for the user and reuses it
if found. Otherwise, generates a new key.
"""
# Check if user already has keys in LiteLLM
existing_keys = await LiteLlmManager.get_user_keys(self.user_id)
if existing_keys:
logger.info(
'saas_settings_store:store:user_already_has_keys',
extra={'user_id': self.user_id, 'key_count': len(existing_keys)},
)
return
# 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(),
# Generate new key only if none exists
generated_key = await LiteLlmManager.generate_key(
self.user_id,
org_id,
openhands_type=openhands_type,
):
generated_key = None
if openhands_type:
generated_key = await LiteLlmManager.generate_key(
self.user_id,
org_id,
None,
{'type': 'openhands'},
)
else:
# Must delete any existing key with the same alias first
key_alias = get_openhands_cloud_key_alias(self.user_id, org_id)
await LiteLlmManager.delete_key_by_alias(key_alias=key_alias)
generated_key = await LiteLlmManager.generate_key(
self.user_id,
org_id,
key_alias,
None,
)
None,
{'type': 'openhands'},
)
if generated_key:
item.llm_api_key = SecretStr(generated_key)
logger.info(
'saas_settings_store:store:generated_openhands_key',
extra={'user_id': self.user_id},
)
else:
logger.warning(
'saas_settings_store:store:failed_to_generate_openhands_key',
extra={'user_id': self.user_id},
)
if generated_key:
item.llm_api_key = SecretStr(generated_key)
logger.info(
'saas_settings_store:store:generated_openhands_key',
extra={'user_id': self.user_id},
)
else:
logger.warning(
'saas_settings_store:store:failed_to_generate_openhands_key',
extra={'user_id': self.user_id},
)

View File

@@ -27,7 +27,6 @@ from storage.org_member import OrgMember
from storage.role_store import RoleStore
from storage.user import User
from storage.user_settings import UserSettings
from utils.identity import resolve_display_name
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
@@ -54,8 +53,7 @@ class UserStore:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_name=resolve_display_name(user_info)
or user_info.get('preferred_username', ''),
contact_name=user_info['preferred_username'],
contact_email=user_info['email'],
v1_enabled=True,
)
@@ -177,8 +175,7 @@ class UserStore:
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
org_version=user_settings.user_version,
contact_name=resolve_display_name(user_info)
or user_info.get('username', ''),
contact_name=user_info['username'],
contact_email=user_info['email'],
)
session.add(org)
@@ -759,49 +756,6 @@ class UserStore:
with session_maker() as session:
return session.query(User).all()
@staticmethod
async def backfill_contact_name(user_id: str, user_info: dict) -> None:
"""Update contact_name on the personal org if it still has a username-style value.
Called during login to gradually fix existing users whose contact_name
was stored as their username (before the resolve_display_name fix).
Preserves custom values that were set via the PATCH endpoint.
"""
real_name = resolve_display_name(user_info)
if not real_name:
logger.debug(
'backfill_contact_name:no_real_name',
extra={'user_id': user_id},
)
return
preferred_username = user_info.get('preferred_username', '')
username = user_info.get('username', '')
async with a_session_maker() as session:
result = await session.execute(
select(Org).filter(Org.id == uuid.UUID(user_id))
)
org = result.scalars().first()
if not org:
logger.debug(
'backfill_contact_name:org_not_found',
extra={'user_id': user_id},
)
return
if org.contact_name in (preferred_username, username):
logger.info(
'backfill_contact_name:updated',
extra={
'user_id': user_id,
'old': org.contact_name,
'new': real_name,
},
)
org.contact_name = real_name
await session.commit()
# Prevent circular imports
from typing import TYPE_CHECKING

View File

@@ -153,7 +153,6 @@ async def test_keycloak_callback_user_not_allowed(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = False
@@ -189,7 +188,6 @@ async def test_keycloak_callback_success_with_valid_offline_token(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
@@ -261,7 +259,6 @@ async def test_keycloak_callback_email_not_verified(mock_request):
mock_user.current_org_id = 'test_org_id'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
# Act
result = await keycloak_callback(
@@ -309,7 +306,6 @@ async def test_keycloak_callback_email_not_verified_missing_field(mock_request):
mock_user.current_org_id = 'test_org_id'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
# Act
result = await keycloak_callback(
@@ -351,7 +347,6 @@ async def test_keycloak_callback_success_without_offline_token(mock_request):
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.migrate_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_token_manager.get_keycloak_tokens = AsyncMock(
return_value=('test_access_token', 'test_refresh_token')
@@ -586,7 +581,6 @@ async def test_keycloak_callback_blocked_email_domain(mock_request):
mock_user.current_org_id = 'test_org_id'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = True
@@ -650,7 +644,6 @@ async def test_keycloak_callback_allowed_email_domain(mock_request):
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_domain_blocker.is_active.return_value = True
mock_domain_blocker.is_domain_blocked.return_value = False
@@ -714,7 +707,6 @@ async def test_keycloak_callback_domain_blocking_inactive(mock_request):
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_domain_blocker.is_active.return_value = False
mock_domain_blocker.is_domain_blocked.return_value = False
@@ -776,7 +768,6 @@ async def test_keycloak_callback_missing_email(mock_request):
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_domain_blocker.is_active.return_value = True
@@ -822,7 +813,6 @@ async def test_keycloak_callback_duplicate_email_detected(mock_request):
mock_user.current_org_id = 'test_org_id'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
# Act
result = await keycloak_callback(
@@ -867,7 +857,6 @@ async def test_keycloak_callback_duplicate_email_deletion_fails(mock_request):
mock_user.current_org_id = 'test_org_id'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
# Act
result = await keycloak_callback(
@@ -925,7 +914,6 @@ async def test_keycloak_callback_duplicate_check_exception(mock_request):
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -983,7 +971,6 @@ async def test_keycloak_callback_no_duplicate_email(mock_request):
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1044,7 +1031,6 @@ async def test_keycloak_callback_no_email_in_user_info(mock_request):
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1201,7 +1187,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1266,7 +1251,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.current_org_id = 'test_org_id'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_domain_blocker.is_domain_blocked.return_value = False
@@ -1349,7 +1333,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1437,7 +1420,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1522,7 +1504,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1606,7 +1587,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1687,7 +1667,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1754,7 +1733,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1827,7 +1805,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.accepted_tos = '2025-01-01'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_verifier.is_active.return_value = True
mock_verifier.is_user_allowed.return_value = True
@@ -1898,7 +1875,6 @@ class TestKeycloakCallbackRecaptcha:
mock_user.current_org_id = 'test_org_id'
mock_user_store.get_user_by_id_async = AsyncMock(return_value=mock_user)
mock_user_store.create_user = AsyncMock(return_value=mock_user)
mock_user_store.backfill_contact_name = AsyncMock()
mock_domain_blocker.is_domain_blocked.return_value = False

View File

@@ -1,116 +0,0 @@
"""Tests for the resolve_display_name helper.
The resolve_display_name helper extracts the best available display name from
Keycloak user_info claims. It is used by both the /api/user/info fallback path
and the user_store create/migrate paths to avoid duplicating name-resolution logic.
The fallback chain is: name → given_name + family_name → None.
It intentionally does NOT fall back to preferred_username/username — callers
that need a guaranteed non-None value handle that separately, because the
/api/user/info route should return name=None when no real name is available.
"""
from utils.identity import resolve_display_name
class TestResolveDisplayName:
"""Test resolve_display_name with various Keycloak claim combinations."""
def test_returns_name_when_present(self):
"""When user_info has a 'name' claim, use it directly."""
user_info = {
'sub': '123',
'name': 'Jane Doe',
'given_name': 'Jane',
'family_name': 'Doe',
'preferred_username': 'j.doe',
}
assert resolve_display_name(user_info) == 'Jane Doe'
def test_combines_given_and_family_name_when_name_absent(self):
"""When 'name' is missing, combine given_name + family_name."""
user_info = {
'sub': '123',
'given_name': 'Jane',
'family_name': 'Doe',
'preferred_username': 'j.doe',
}
assert resolve_display_name(user_info) == 'Jane Doe'
def test_uses_given_name_only_when_family_name_absent(self):
"""When only given_name is available, use it alone."""
user_info = {
'sub': '123',
'given_name': 'Jane',
'preferred_username': 'j.doe',
}
assert resolve_display_name(user_info) == 'Jane'
def test_uses_family_name_only_when_given_name_absent(self):
"""When only family_name is available, use it alone."""
user_info = {
'sub': '123',
'family_name': 'Doe',
'preferred_username': 'j.doe',
}
assert resolve_display_name(user_info) == 'Doe'
def test_returns_none_when_no_name_claims(self):
"""When no name claims exist at all, return None."""
user_info = {
'sub': '123',
'preferred_username': 'j.doe',
'email': 'jane@example.com',
}
assert resolve_display_name(user_info) is None
def test_returns_none_for_empty_name(self):
"""When 'name' is an empty string, treat it as absent."""
user_info = {
'sub': '123',
'name': '',
'preferred_username': 'j.doe',
}
assert resolve_display_name(user_info) is None
def test_returns_none_for_whitespace_only_name(self):
"""When 'name' is whitespace only, treat it as absent."""
user_info = {
'sub': '123',
'name': ' ',
'preferred_username': 'j.doe',
}
assert resolve_display_name(user_info) is None
def test_returns_none_for_empty_given_and_family_names(self):
"""When given_name and family_name are both empty strings, return None."""
user_info = {
'sub': '123',
'given_name': '',
'family_name': '',
'preferred_username': 'j.doe',
}
assert resolve_display_name(user_info) is None
def test_returns_none_for_empty_dict(self):
"""An empty user_info dict returns None."""
assert resolve_display_name({}) is None
def test_strips_whitespace_from_combined_name(self):
"""Whitespace around given_name/family_name is stripped."""
user_info = {
'sub': '123',
'given_name': ' Jane ',
'family_name': ' Doe ',
}
assert resolve_display_name(user_info) == 'Jane Doe'
def test_name_claim_takes_priority_over_given_family(self):
"""When both 'name' and given/family are present, 'name' wins."""
user_info = {
'sub': '123',
'name': 'Dr. Jane Doe',
'given_name': 'Jane',
'family_name': 'Doe',
}
assert resolve_display_name(user_info) == 'Dr. Jane Doe'

View File

@@ -11,11 +11,7 @@ from pydantic import SecretStr
from server.constants import (
get_default_litellm_model,
)
from storage.lite_llm_manager import (
LiteLlmManager,
get_byor_key_alias,
get_openhands_cloud_key_alias,
)
from storage.lite_llm_manager import LiteLlmManager
from storage.user_settings import UserSettings
from openhands.server.settings import Settings
@@ -1551,321 +1547,3 @@ class TestLiteLlmManager:
# making any LiteLLM calls
assert result is not None
assert result.agent == 'TestAgent'
class TestGetAllKeysForUser:
"""Test cases for _get_all_keys_for_user method."""
@pytest.mark.asyncio
async def test_get_all_keys_missing_config(self):
"""Test _get_all_keys_for_user when LiteLLM config is missing."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', None):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', None):
result = await LiteLlmManager._get_all_keys_for_user(
mock_client, 'test-user-id'
)
assert result == []
mock_client.get.assert_not_called()
@pytest.mark.asyncio
async def test_get_all_keys_success(self):
"""Test _get_all_keys_for_user returns keys on success."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
'keys': [
{
'key_name': 'sk-test1234',
'key_alias': 'test-alias',
'team_id': 'test-org',
'metadata': {'type': 'openhands'},
},
{
'key_name': 'sk-test5678',
'key_alias': 'another-alias',
'team_id': 'test-org',
'metadata': None,
},
]
}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
result = await LiteLlmManager._get_all_keys_for_user(
mock_client, 'test-user-id'
)
assert len(result) == 2
assert result[0]['key_name'] == 'sk-test1234'
assert result[1]['key_name'] == 'sk-test5678'
# Verify API key header is included
mock_client.get.assert_called_once()
call_kwargs = mock_client.get.call_args
assert call_kwargs.kwargs['headers'] == {
'x-goog-api-key': 'test-api-key'
}
@pytest.mark.asyncio
async def test_get_all_keys_empty_response(self):
"""Test _get_all_keys_for_user returns empty list when user has no keys."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {'keys': []}
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
result = await LiteLlmManager._get_all_keys_for_user(
mock_client, 'test-user-id'
)
assert result == []
@pytest.mark.asyncio
async def test_get_all_keys_api_error(self):
"""Test _get_all_keys_for_user handles API errors gracefully."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_client.get.side_effect = Exception('API Error')
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
result = await LiteLlmManager._get_all_keys_for_user(
mock_client, 'test-user-id'
)
assert result == []
class TestVerifyExistingKey:
"""Test cases for _verify_existing_key method."""
@pytest.mark.asyncio
async def test_verify_existing_key_openhands_type_found(self):
"""Test _verify_existing_key finds matching OpenHands key."""
mock_keys = [
{
'key_name': 'sk-test1234',
'key_alias': 'some-alias',
'team_id': 'test-org',
'metadata': {'type': 'openhands'},
}
]
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = mock_keys
# Key ending with '1234' should match 'sk-test1234'
result = await LiteLlmManager._verify_existing_key(
mock_client,
'my-key-ending-with-1234',
'test-user-id',
'test-org',
openhands_type=True,
)
assert result is True
@pytest.mark.asyncio
async def test_verify_existing_key_openhands_type_not_found(self):
"""Test _verify_existing_key returns False when key doesn't match."""
mock_keys = [
{
'key_name': 'sk-test1234',
'key_alias': 'some-alias',
'team_id': 'test-org',
'metadata': {'type': 'openhands'},
}
]
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = mock_keys
# Key ending with '5678' should NOT match 'sk-test1234'
result = await LiteLlmManager._verify_existing_key(
mock_client,
'my-key-ending-with-5678',
'test-user-id',
'test-org',
openhands_type=True,
)
assert result is False
@pytest.mark.asyncio
async def test_verify_existing_key_by_alias_openhands_cloud(self):
"""Test _verify_existing_key finds key by OpenHands Cloud alias."""
user_id = 'test-user-id'
org_id = 'test-org'
mock_keys = [
{
'key_name': 'sk-testABCD',
'key_alias': get_openhands_cloud_key_alias(user_id, org_id),
'team_id': org_id,
'metadata': None,
}
]
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = mock_keys
result = await LiteLlmManager._verify_existing_key(
mock_client,
'my-key-ending-with-ABCD',
user_id,
org_id,
openhands_type=False,
)
assert result is True
@pytest.mark.asyncio
async def test_verify_existing_key_by_alias_byor(self):
"""Test _verify_existing_key finds key by BYOR alias."""
user_id = 'test-user-id'
org_id = 'test-org'
mock_keys = [
{
'key_name': 'sk-testXYZW',
'key_alias': get_byor_key_alias(user_id, org_id),
'team_id': org_id,
'metadata': None,
}
]
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = mock_keys
result = await LiteLlmManager._verify_existing_key(
mock_client,
'my-key-ending-with-XYZW',
user_id,
org_id,
openhands_type=False,
)
assert result is True
@pytest.mark.asyncio
async def test_verify_existing_key_wrong_team(self):
"""Test _verify_existing_key returns False for wrong team_id."""
mock_keys = [
{
'key_name': 'sk-test1234',
'key_alias': 'some-alias',
'team_id': 'different-org',
'metadata': {'type': 'openhands'},
}
]
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = mock_keys
result = await LiteLlmManager._verify_existing_key(
mock_client,
'my-key-ending-with-1234',
'test-user-id',
'test-org',
openhands_type=True,
)
assert result is False
@pytest.mark.asyncio
async def test_verify_existing_key_no_keys(self):
"""Test _verify_existing_key returns False when user has no keys."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = []
result = await LiteLlmManager._verify_existing_key(
mock_client,
'some-key-value',
'test-user-id',
'test-org',
openhands_type=True,
)
assert result is False
@pytest.mark.asyncio
async def test_verify_existing_key_handles_none_key_name(self):
"""Test _verify_existing_key handles None key_name gracefully."""
mock_keys = [
{
'key_name': None,
'key_alias': 'some-alias',
'team_id': 'test-org',
'metadata': {'type': 'openhands'},
}
]
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = mock_keys
# Should not raise TypeError, should return False
result = await LiteLlmManager._verify_existing_key(
mock_client,
'some-key-value',
'test-user-id',
'test-org',
openhands_type=True,
)
assert result is False
@pytest.mark.asyncio
async def test_verify_existing_key_handles_empty_key_name(self):
"""Test _verify_existing_key handles empty key_name gracefully."""
mock_keys = [
{
'key_name': '',
'key_alias': 'some-alias',
'team_id': 'test-org',
'metadata': {'type': 'openhands'},
}
]
mock_client = AsyncMock(spec=httpx.AsyncClient)
with patch.object(
LiteLlmManager, '_get_all_keys_for_user', new_callable=AsyncMock
) as mock_get_keys:
mock_get_keys.return_value = mock_keys
# Should not raise error, should return False
result = await LiteLlmManager._verify_existing_key(
mock_client,
'some-key-value',
'test-user-id',
'test-org',
openhands_type=True,
)
assert result is False

View File

@@ -94,7 +94,6 @@ class TestRecaptchaServiceCreateAssessment:
"""Test that assessment allows request when score is above threshold."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/abc123'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
@@ -111,7 +110,6 @@ class TestRecaptchaServiceCreateAssessment:
# Assert
assert isinstance(result, AssessmentResult)
assert result.name == 'projects/test-project/assessments/abc123'
assert result.allowed is True
assert result.score == 0.9
assert result.valid is True
@@ -124,7 +122,6 @@ class TestRecaptchaServiceCreateAssessment:
"""Test that assessment blocks request when score is below threshold."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/def456'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.2
@@ -149,7 +146,6 @@ class TestRecaptchaServiceCreateAssessment:
"""Test that assessment blocks request when token is invalid."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/ghi789'
mock_response.token_properties.valid = False
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
@@ -174,7 +170,6 @@ class TestRecaptchaServiceCreateAssessment:
"""Test that assessment blocks request when action doesn't match."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/jkl012'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'SIGNUP'
mock_response.risk_analysis.score = 0.9
@@ -199,7 +194,6 @@ class TestRecaptchaServiceCreateAssessment:
"""Test that email is included in user_info when provided."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/mno345'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
@@ -229,7 +223,6 @@ class TestRecaptchaServiceCreateAssessment:
"""Test that user_info is not included when email is None."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/pqr678'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
@@ -255,13 +248,10 @@ class TestRecaptchaServiceCreateAssessment:
# If user_info exists, verify account_id is empty (not set)
assert not assessment.event.user_info.account_id
def test_should_log_assessment_details_including_name(
self, recaptcha_service, mock_gcp_client
):
"""Test that assessment details including assessment name are logged."""
def test_should_log_assessment_details(self, recaptcha_service, mock_gcp_client):
"""Test that assessment details are logged."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/stu901'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
@@ -281,73 +271,11 @@ class TestRecaptchaServiceCreateAssessment:
mock_logger.info.assert_called_once()
call_kwargs = mock_logger.info.call_args
assert call_kwargs[0][0] == 'recaptcha_assessment'
assert (
call_kwargs[1]['extra']['assessment_name']
== 'projects/test-project/assessments/stu901'
)
assert call_kwargs[1]['extra']['score'] == 0.9
assert call_kwargs[1]['extra']['valid'] is True
assert call_kwargs[1]['extra']['action_valid'] is True
assert call_kwargs[1]['extra']['user_ip'] == '192.168.1.1'
def test_should_log_user_id_and_email_when_provided(
self, recaptcha_service, mock_gcp_client
):
"""Test that user_id and email are included in log when provided."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/xyz123'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
with patch('server.auth.recaptcha_service.logger') as mock_logger:
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
email='test@example.com',
user_id='keycloak-user-123',
)
# Assert
mock_logger.info.assert_called_once()
call_kwargs = mock_logger.info.call_args
assert call_kwargs[1]['extra']['user_id'] == 'keycloak-user-123'
assert call_kwargs[1]['extra']['email'] == 'test@example.com'
def test_should_log_none_for_user_id_and_email_when_not_provided(
self, recaptcha_service, mock_gcp_client
):
"""Test that user_id and email are logged as None when not provided."""
# Arrange
mock_response = MagicMock()
mock_response.name = 'projects/test-project/assessments/abc456'
mock_response.token_properties.valid = True
mock_response.token_properties.action = 'LOGIN'
mock_response.risk_analysis.score = 0.9
mock_response.risk_analysis.reasons = []
mock_gcp_client.create_assessment.return_value = mock_response
with patch('server.auth.recaptcha_service.logger') as mock_logger:
# Act
recaptcha_service.create_assessment(
token='test-token',
action='LOGIN',
user_ip='192.168.1.1',
user_agent='Mozilla/5.0',
)
# Assert
mock_logger.info.assert_called_once()
call_kwargs = mock_logger.info.call_args
assert call_kwargs[1]['extra']['user_id'] is None
assert call_kwargs[1]['extra']['email'] is None
def test_should_raise_exception_when_gcp_client_fails(
self, recaptcha_service, mock_gcp_client
):

View File

@@ -1,11 +1,10 @@
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch
import pytest
from pydantic import SecretStr
from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.server.settings import Settings
from openhands.storage.data_models.settings import Settings as DataSettings
# Mock the database module before importing
with patch('storage.database.engine'), patch('storage.database.a_engine'):
@@ -177,53 +176,3 @@ async def test_encryption(settings_store):
# 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'
@pytest.mark.asyncio
async def test_ensure_api_key_keeps_valid_key(mock_config):
"""When the existing key is valid, it should be kept unchanged."""
store = SaasSettingsStore('test-user-id-123', MagicMock(), mock_config)
existing_key = 'sk-existing-key'
item = DataSettings(
llm_model='openhands/gpt-4', llm_api_key=SecretStr(existing_key)
)
with patch(
'storage.saas_settings_store.LiteLlmManager.verify_existing_key',
new_callable=AsyncMock,
return_value=True,
):
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
@pytest.mark.asyncio
async def test_ensure_api_key_generates_new_key_when_verification_fails(
mock_config,
):
"""When verification fails, a new key should be generated."""
store = SaasSettingsStore('test-user-id-123', MagicMock(), mock_config)
new_key = 'sk-new-key'
item = DataSettings(
llm_model='openhands/gpt-4', llm_api_key=SecretStr('sk-invalid-key')
)
with (
patch(
'storage.saas_settings_store.LiteLlmManager.verify_existing_key',
new_callable=AsyncMock,
return_value=False,
),
patch(
'storage.saas_settings_store.LiteLlmManager.generate_key',
new_callable=AsyncMock,
return_value=new_key,
),
):
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

View File

@@ -1,163 +0,0 @@
"""Tests for the fallback User path in the /api/user/info endpoint.
When a user authenticates via Keycloak without provider tokens (e.g., SAML, enterprise SSO),
the endpoint constructs a User from OIDC claims. These tests verify that name and company
fields are correctly populated from Keycloak claims in this fallback path.
"""
from unittest.mock import AsyncMock, patch
import pytest
from pydantic import SecretStr
from openhands.integrations.service_types import User
@pytest.fixture
def mock_token_manager():
"""Mock the token_manager used by user.py routes."""
with patch('server.routes.user.token_manager') as mock_tm:
yield mock_tm
@pytest.fixture
def mock_check_idp():
"""Mock _check_idp to pass through the default_value (the fallback User).
This isolates the test to just the User construction logic in saas_get_user,
without needing to set up Keycloak IDP token checks.
"""
with patch('server.routes.user._check_idp') as mock_fn:
# Return the default_value argument that was passed to _check_idp
mock_fn.side_effect = lambda **kwargs: kwargs.get('default_value')
yield mock_fn
@pytest.mark.asyncio
async def test_fallback_user_includes_name_from_name_claim(
mock_token_manager, mock_check_idp
):
"""When Keycloak provides a 'name' claim, the fallback User should include it."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'name': 'Jane Doe',
'preferred_username': 'j.doe',
'email': 'jane@example.com',
}
)
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.name == 'Jane Doe'
assert result.email == 'jane@example.com'
@pytest.mark.asyncio
async def test_fallback_user_combines_given_and_family_name(
mock_token_manager, mock_check_idp
):
"""When 'name' is absent, combine given_name + family_name."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'given_name': 'Jane',
'family_name': 'Doe',
'preferred_username': 'j.doe',
'email': 'jane@example.com',
}
)
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.name == 'Jane Doe'
@pytest.mark.asyncio
async def test_fallback_user_name_is_none_when_no_name_claims(
mock_token_manager, mock_check_idp
):
"""When no name claims exist, name should be None."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'preferred_username': 'j.doe',
'email': 'jane@example.com',
}
)
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.name is None
@pytest.mark.asyncio
async def test_fallback_user_includes_company_claim(mock_token_manager, mock_check_idp):
"""When Keycloak provides a 'company' claim, include it in the User."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'name': 'Jane Doe',
'preferred_username': 'j.doe',
'email': 'jane@example.com',
'company': 'Acme Corp',
}
)
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.company == 'Acme Corp'
@pytest.mark.asyncio
async def test_fallback_user_company_is_none_when_absent(
mock_token_manager, mock_check_idp
):
"""When 'company' is not in Keycloak claims, company should be None."""
from server.routes.user import saas_get_user
mock_token_manager.get_user_info = AsyncMock(
return_value={
'sub': '248289761001',
'name': 'Jane Doe',
'preferred_username': 'j.doe',
'email': 'jane@example.com',
}
)
result = await saas_get_user(
provider_tokens=None,
access_token=SecretStr('test-token'),
user_id='248289761001',
)
assert isinstance(result, User)
assert result.company is None

View File

@@ -1,15 +1,15 @@
import uuid
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import SecretStr
from sqlalchemy.orm import configure_mappers
# Database connection is lazy (no module-level engines), so no patching needed
from storage.org import Org
from storage.user import User
from storage.user_store import UserStore
# Mock the database module before importing UserStore
with patch('storage.database.engine'), patch('storage.database.a_engine'):
from storage.user import User
from storage.user_store import UserStore
from sqlalchemy.orm import configure_mappers
from openhands.storage.data_models.settings import Settings
@@ -162,363 +162,3 @@ def test_get_kwargs_from_settings():
assert 'enable_sound_notifications' in kwargs
# Should not include fields that don't exist in User model
assert 'llm_api_key' not in kwargs
# --- Tests for contact_name resolution in migrate_user() ---
# migrate_user() should use resolve_display_name() to populate contact_name
# from Keycloak name claims, falling back to username only when no real name
# is available. This mirrors the create_user() fix and ensures migrated Org
# records also store the user's actual display name.
class _StopAfterOrgCreation(Exception):
"""Halt migrate_user() after Org creation for contact_name inspection."""
pass
@pytest.mark.asyncio
async def test_migrate_user_contact_name_uses_name_claim():
"""When user_info has a 'name' claim, migrate_user() should use it for contact_name."""
user_id = str(uuid.uuid4())
user_info = {
'username': 'jdoe',
'email': 'jdoe@example.com',
'name': 'John Doe',
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
mock_user_settings = MagicMock()
mock_user_settings.user_version = 1
with (
patch('storage.user_store.session_maker', mock_sm),
patch(
'storage.user_store.decrypt_legacy_model',
return_value={'keycloak_user_id': user_id},
),
patch('storage.user_store.UserSettings'),
patch(
'storage.lite_llm_manager.LiteLlmManager.migrate_entries',
new_callable=AsyncMock,
side_effect=_StopAfterOrgCreation,
),
):
with pytest.raises(_StopAfterOrgCreation):
await UserStore.migrate_user(user_id, mock_user_settings, user_info)
org = mock_session.add.call_args_list[0][0][0]
assert isinstance(org, Org)
assert org.contact_name == 'John Doe'
@pytest.mark.asyncio
async def test_migrate_user_contact_name_uses_given_family_names():
"""When only given_name and family_name are present, migrate_user() should combine them."""
user_id = str(uuid.uuid4())
user_info = {
'username': 'jsmith',
'email': 'jsmith@example.com',
'given_name': 'Jane',
'family_name': 'Smith',
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
mock_user_settings = MagicMock()
mock_user_settings.user_version = 1
with (
patch('storage.user_store.session_maker', mock_sm),
patch(
'storage.user_store.decrypt_legacy_model',
return_value={'keycloak_user_id': user_id},
),
patch('storage.user_store.UserSettings'),
patch(
'storage.lite_llm_manager.LiteLlmManager.migrate_entries',
new_callable=AsyncMock,
side_effect=_StopAfterOrgCreation,
),
):
with pytest.raises(_StopAfterOrgCreation):
await UserStore.migrate_user(user_id, mock_user_settings, user_info)
org = mock_session.add.call_args_list[0][0][0]
assert isinstance(org, Org)
assert org.contact_name == 'Jane Smith'
@pytest.mark.asyncio
async def test_migrate_user_contact_name_falls_back_to_username():
"""When no name claims exist, migrate_user() should fall back to username."""
user_id = str(uuid.uuid4())
user_info = {
'username': 'jdoe',
'email': 'jdoe@example.com',
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
mock_user_settings = MagicMock()
mock_user_settings.user_version = 1
with (
patch('storage.user_store.session_maker', mock_sm),
patch(
'storage.user_store.decrypt_legacy_model',
return_value={'keycloak_user_id': user_id},
),
patch('storage.user_store.UserSettings'),
patch(
'storage.lite_llm_manager.LiteLlmManager.migrate_entries',
new_callable=AsyncMock,
side_effect=_StopAfterOrgCreation,
),
):
with pytest.raises(_StopAfterOrgCreation):
await UserStore.migrate_user(user_id, mock_user_settings, user_info)
org = mock_session.add.call_args_list[0][0][0]
assert isinstance(org, Org)
assert org.contact_name == 'jdoe'
# --- Tests for contact_name resolution in create_user() ---
# create_user() should use resolve_display_name() to populate contact_name
# from Keycloak name claims, falling back to preferred_username only when
# no real name is available. This ensures Org records store the user's
# actual display name for use in UI and analytics.
@pytest.mark.asyncio
async def test_create_user_contact_name_uses_name_claim():
"""When user_info has a 'name' claim, create_user() should use it for contact_name."""
user_id = str(uuid.uuid4())
user_info = {
'preferred_username': 'jdoe',
'email': 'jdoe@example.com',
'name': 'John Doe',
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
with (
patch('storage.user_store.session_maker', mock_sm),
patch.object(
UserStore,
'create_default_settings',
new_callable=AsyncMock,
return_value=None,
),
):
result = await UserStore.create_user(user_id, user_info)
assert result is None # create_default_settings returned None
# The Org should have been added to the session with the real display name
org = mock_session.add.call_args_list[0][0][0]
assert isinstance(org, Org)
assert org.contact_name == 'John Doe'
@pytest.mark.asyncio
async def test_create_user_contact_name_uses_given_family_names():
"""When only given_name and family_name are present, create_user() should combine them."""
user_id = str(uuid.uuid4())
user_info = {
'preferred_username': 'jsmith',
'email': 'jsmith@example.com',
'given_name': 'Jane',
'family_name': 'Smith',
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
with (
patch('storage.user_store.session_maker', mock_sm),
patch.object(
UserStore,
'create_default_settings',
new_callable=AsyncMock,
return_value=None,
),
):
result = await UserStore.create_user(user_id, user_info)
assert result is None
org = mock_session.add.call_args_list[0][0][0]
assert isinstance(org, Org)
assert org.contact_name == 'Jane Smith'
@pytest.mark.asyncio
async def test_create_user_contact_name_falls_back_to_username():
"""When no name claims exist, create_user() should fall back to preferred_username."""
user_id = str(uuid.uuid4())
user_info = {
'preferred_username': 'jdoe',
'email': 'jdoe@example.com',
}
mock_session = MagicMock()
mock_sm = MagicMock()
mock_sm.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_sm.return_value.__exit__ = MagicMock(return_value=False)
with (
patch('storage.user_store.session_maker', mock_sm),
patch.object(
UserStore,
'create_default_settings',
new_callable=AsyncMock,
return_value=None,
),
):
result = await UserStore.create_user(user_id, user_info)
assert result is None
org = mock_session.add.call_args_list[0][0][0]
assert isinstance(org, Org)
assert org.contact_name == 'jdoe'
# --- Tests for backfill_contact_name on login ---
# Existing users created before the resolve_display_name fix may have
# username-style values in contact_name. The backfill updates these to
# the user's real display name when they next log in, but preserves
# custom values set via the PATCH endpoint.
def _wrap_sync_as_async_session_maker(sync_sm):
"""Wrap a sync session_maker so it can be used in place of a_session_maker."""
@asynccontextmanager
async def _async_sm():
session = sync_sm()
try:
class _AsyncWrapper:
async def execute(self, *args, **kwargs):
return session.execute(*args, **kwargs)
async def commit(self):
session.commit()
yield _AsyncWrapper()
finally:
session.close()
return _async_sm
@pytest.mark.asyncio
async def test_backfill_contact_name_updates_when_matches_preferred_username(
session_maker,
):
"""When contact_name matches preferred_username and a real name is available, update it."""
user_id = str(uuid.uuid4())
# Create org with username-style contact_name (as create_user used to store)
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_name='jdoe',
contact_email='jdoe@example.com',
)
session.add(org)
session.commit()
user_info = {
'preferred_username': 'jdoe',
'name': 'John Doe',
}
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.backfill_contact_name(user_id, user_info)
with session_maker() as session:
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
assert org.contact_name == 'John Doe'
@pytest.mark.asyncio
async def test_backfill_contact_name_updates_when_matches_username(session_maker):
"""When contact_name matches username (migrate_user legacy) and a real name is available, update it."""
user_id = str(uuid.uuid4())
# Create org with username-style contact_name (as migrate_user used to store)
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_name='jdoe',
contact_email='jdoe@example.com',
)
session.add(org)
session.commit()
user_info = {
'username': 'jdoe',
'given_name': 'Jane',
'family_name': 'Doe',
}
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.backfill_contact_name(user_id, user_info)
with session_maker() as session:
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
assert org.contact_name == 'Jane Doe'
@pytest.mark.asyncio
async def test_backfill_contact_name_preserves_custom_value(session_maker):
"""When contact_name differs from both username fields, do not overwrite it."""
user_id = str(uuid.uuid4())
# Org has a custom contact_name set via PATCH endpoint
with session_maker() as session:
org = Org(
id=uuid.UUID(user_id),
name=f'user_{user_id}_org',
contact_name='Custom Corp Name',
contact_email='jdoe@example.com',
)
session.add(org)
session.commit()
user_info = {
'preferred_username': 'jdoe',
'username': 'jdoe',
'name': 'John Doe',
}
with patch(
'storage.user_store.a_session_maker',
_wrap_sync_as_async_session_maker(session_maker),
):
await UserStore.backfill_contact_name(user_id, user_info)
with session_maker() as session:
org = session.query(Org).filter(Org.id == uuid.UUID(user_id)).first()
assert org.contact_name == 'Custom Corp Name'

View File

@@ -1,25 +0,0 @@
"""Shared identity helpers for resolving user profile fields from Keycloak claims."""
def resolve_display_name(user_info: dict) -> str | None:
"""Resolve the best available display name from Keycloak user_info claims.
Fallback chain: name → given_name + family_name → None
Does NOT fall back to preferred_username/username — callers that need
a guaranteed non-None value should handle that separately. This keeps
the helper focused on real-name claims so that the /api/user/info route
can return name=None when no real name is available, while user_store
callers can append their own username fallback.
"""
name = user_info.get('name', '')
if name and name.strip():
return name.strip()
given = user_info.get('given_name', '').strip()
family = user_info.get('family_name', '').strip()
combined = f'{given} {family}'.strip()
if combined:
return combined
return None

View File

@@ -201,7 +201,7 @@ describe("GitRepoDropdown", () => {
const selectedRepository = MOCK_REPOSITORIES[0];
renderDropdown(
{ value: selectedRepository.id },
{ value: selectedRepository.full_name },
{ selectedRepository },
);
@@ -230,24 +230,5 @@ describe("GitRepoDropdown", () => {
expect(mockOnChange).toHaveBeenCalledWith(MOCK_REPOSITORIES[1]);
});
it("should keep selected repo visible even if it's not in fetched results", async () => {
// selectedRepository from useRepositoryData stays null in our default mocks,
// which matches the real-world scenario where a recent repo isn't yet loaded.
renderDropdown();
const input = screen.getByTestId("git-repo-dropdown") as HTMLInputElement;
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText("user/repo-two")).toBeInTheDocument();
});
await userEvent.click(screen.getByText("user/repo-two"));
await waitFor(() => {
expect(input.value).toBe("user/repo-two");
});
});
});
});

View File

@@ -17,8 +17,6 @@ describe("PostHogWrapper", () => {
vi.clearAllMocks();
// Reset URL hash
window.location.hash = "";
// Clear sessionStorage
sessionStorage.clear();
// Mock the config fetch
// @ts-expect-error - partial mock
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
@@ -26,9 +24,9 @@ describe("PostHogWrapper", () => {
});
});
it("should initialize PostHog with bootstrap IDs from URL hash (without ph_ prefix)", async () => {
// Webflow sends distinct_id and session_id without the ph_ prefix
window.location.hash = "distinct_id=user-123&session_id=session-456";
it("should initialize PostHog with bootstrap IDs from URL hash", async () => {
// Set up URL hash with cross-domain tracking params
window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456";
render(
<PostHogWrapper>
@@ -36,8 +34,10 @@ describe("PostHogWrapper", () => {
</PostHogWrapper>,
);
// Wait for async config fetch and PostHog initialization
await screen.findByTestId("child");
// Verify PostHogProvider was called with bootstrap options
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
@@ -51,7 +51,8 @@ describe("PostHogWrapper", () => {
});
it("should clean up URL hash after extracting bootstrap IDs", async () => {
window.location.hash = "distinct_id=user-123&session_id=session-456";
// Set up URL hash with cross-domain tracking params
window.location.hash = "ph_distinct_id=user-123&ph_session_id=session-456";
render(
<PostHogWrapper>
@@ -59,98 +60,10 @@ describe("PostHogWrapper", () => {
</PostHogWrapper>,
);
// Wait for async config fetch and PostHog initialization
await screen.findByTestId("child");
// Verify URL hash was cleaned up
expect(window.location.hash).toBe("");
});
it("should persist bootstrap IDs to sessionStorage for OAuth survival", async () => {
window.location.hash = "distinct_id=user-123&session_id=session-456";
render(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
// After extracting from hash, IDs should NOT remain in sessionStorage
// because they were already consumed during this page load.
// But if a full-page redirect happened before PostHog init,
// sessionStorage would still have them for the next load.
// We verify the write happened by checking the provider received the IDs.
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: {
distinctID: "user-123",
sessionID: "session-456",
},
}),
}),
);
});
it("should read bootstrap IDs from sessionStorage when hash is absent (post-OAuth)", async () => {
// Simulate returning from OAuth: no hash, but sessionStorage has the IDs
sessionStorage.setItem(
"posthog_bootstrap",
JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }),
);
render(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: {
distinctID: "user-123",
sessionID: "session-456",
},
}),
}),
);
});
it("should clean up sessionStorage after consuming bootstrap IDs", async () => {
sessionStorage.setItem(
"posthog_bootstrap",
JSON.stringify({ distinctID: "user-123", sessionID: "session-456" }),
);
render(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(sessionStorage.getItem("posthog_bootstrap")).toBeNull();
});
it("should initialize without bootstrap when neither hash nor sessionStorage has IDs", async () => {
render(
<PostHogWrapper>
<div data-testid="child" />
</PostHogWrapper>,
);
await screen.findByTestId("child");
expect(mockPostHogProvider).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
bootstrap: undefined,
}),
}),
);
});
});

View File

@@ -6,7 +6,7 @@ export function ConversationLoading() {
const { t } = useTranslation();
return (
<div className="bg-[#25272D] flex flex-col items-center justify-center h-full w-full">
<div className="bg-[#25272D] border border-[#525252] rounded-xl flex flex-col items-center justify-center h-full w-full">
<LoaderCircle className="animate-spin w-16 h-16" color="white" />
<span className="text-2xl font-normal leading-5 text-white p-4">
{t(I18nKey.HOME$LOADING)}

View File

@@ -91,13 +91,6 @@ export function GitRepoDropdown({
repositoryName,
);
/**
* `selectedRepository` is derived from fetched/search results. A repo selected
* from "Most Recent" may not be present in those results yet, so use the
* locally selected item as a fallback to keep the UI stable.
*/
const effectiveSelectedRepository = selectedRepository ?? localSelectedItem;
// Get recent repositories filtered by provider and input keyword
const recentRepositories = useMemo(() => {
const allRecentRepos = storedRecentRepositories;
@@ -240,9 +233,12 @@ export function GitRepoDropdown({
// Initialize input value when selectedRepository changes (but not when user is typing)
useEffect(() => {
if (isOpen) return;
setInputValue(effectiveSelectedRepository?.full_name ?? "");
}, [effectiveSelectedRepository, isOpen]);
if (selectedRepository && !isOpen) {
setInputValue(selectedRepository.full_name);
} else if (!selectedRepository && !isOpen) {
setInputValue("");
}
}, [selectedRepository, isOpen]);
const isLoadingState =
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
@@ -348,7 +344,7 @@ export function GitRepoDropdown({
/>
<div className="absolute right-1 top-1/2 transform -translate-y-1/2 flex items-center">
{effectiveSelectedRepository && (
{selectedRepository && (
<ClearButton disabled={disabled} onClear={handleClear} />
)}

View File

@@ -3,37 +3,22 @@ import { PostHogProvider } from "posthog-js/react";
import OptionService from "#/api/option-service/option-service.api";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
const POSTHOG_BOOTSTRAP_KEY = "posthog_bootstrap";
function getBootstrapIds() {
// Try to extract from URL hash (e.g. #distinct_id=abc&session_id=xyz)
function getBootstrapFromHash() {
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const distinctId = params.get("distinct_id");
const sessionId = params.get("session_id");
const distinctId = params.get("ph_distinct_id");
const sessionId = params.get("ph_session_id");
if (distinctId && sessionId) {
const bootstrap = { distinctID: distinctId, sessionID: sessionId };
// Persist to sessionStorage so IDs survive full-page OAuth redirects
sessionStorage.setItem(POSTHOG_BOOTSTRAP_KEY, JSON.stringify(bootstrap));
// Clean the hash from the URL
// Remove the PostHog tracking params from URL hash to keep URL clean
// replaceState(state, unused, url) - we pass null state, empty title (ignored by browsers), and the clean URL
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
return bootstrap;
return { distinctID: distinctId, sessionID: sessionId };
}
// Fallback: check sessionStorage (covers return from OAuth redirect)
const stored = sessionStorage.getItem(POSTHOG_BOOTSTRAP_KEY);
if (stored) {
sessionStorage.removeItem(POSTHOG_BOOTSTRAP_KEY);
return JSON.parse(stored) as { distinctID: string; sessionID: string };
}
return undefined;
}
@@ -42,7 +27,7 @@ export function PostHogWrapper({ children }: { children: React.ReactNode }) {
null,
);
const [isLoading, setIsLoading] = React.useState(true);
const bootstrapIds = React.useMemo(() => getBootstrapIds(), []);
const bootstrapIds = React.useMemo(() => getBootstrapFromHash(), []);
React.useEffect(() => {
(async () => {

View File

@@ -1018,6 +1018,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
new_content[-1] = TextContent(
text=last_content.text + plugin_params_message,
cache_prompt=last_content.cache_prompt,
enable_truncation=last_content.enable_truncation,
)
else:
# Add as new text content

View File

@@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
# The version of the agent server to use for deployments.
# Typically this will be the same as the values from the pyproject.toml
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:b7d5cdb-python'
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:c775ff6-python'
class SandboxSpecService(ABC):

View File

@@ -21,7 +21,6 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
recaptcha_site_key: str | None = None
faulty_models: list[str] = Field(default_factory=list)
error_message: str | None = None
github_app_slug: str | None = None
async def get_web_client_config(self) -> WebClientConfig:
from openhands.app_server.config import get_global_config
@@ -37,6 +36,5 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
recaptcha_site_key=self.recaptcha_site_key,
faulty_models=self.faulty_models,
error_message=self.error_message,
github_app_slug=self.github_app_slug,
)
return result

View File

@@ -25,4 +25,3 @@ class WebClientConfig(DiscriminatedUnionMixin):
recaptcha_site_key: str | None
faulty_models: list[str]
error_message: str | None
github_app_slug: str | None

View File

@@ -4,7 +4,6 @@ from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.protocols.http_client import HTTPClient
from openhands.integrations.service_types import (
BaseGitService,
@@ -25,18 +24,6 @@ class BitBucketMixinBase(BaseGitService, HTTPClient):
BASE_URL = 'https://api.bitbucket.org/2.0'
@staticmethod
def _resolve_primary_email(emails: list[dict]) -> str | None:
"""Find the primary confirmed email from a list of Bitbucket email objects.
Bitbucket's /user/emails endpoint returns objects with
'email', 'is_primary', and 'is_confirmed' keys.
"""
for entry in emails:
if entry.get('is_primary') and entry.get('is_confirmed'):
return entry.get('email')
return None
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
"""Extract owner and repo from repository string.
@@ -150,17 +137,6 @@ class BitBucketMixinBase(BaseGitService, HTTPClient):
return all_items[:max_items]
async def get_user_emails(self) -> list[dict]:
"""Fetch the authenticated user's email addresses from Bitbucket.
Calls GET /user/emails which returns a paginated response with a
'values' list of email objects containing 'email', 'is_primary',
and 'is_confirmed' fields.
"""
url = f'{self.BASE_URL}/user/emails'
response, _ = await self._make_request(url)
return response.get('values', [])
async def get_user(self) -> User:
"""Get the authenticated user's information."""
url = f'{self.BASE_URL}/user'
@@ -168,22 +144,12 @@ class BitBucketMixinBase(BaseGitService, HTTPClient):
account_id = data.get('account_id', '')
email = None
try:
emails = await self.get_user_emails()
email = self._resolve_primary_email(emails)
except Exception:
logger.warning(
'bitbucket:get_user:email_fallback_failed',
exc_info=True,
)
return User(
id=account_id,
login=data.get('username', ''),
avatar_url=data.get('links', {}).get('avatar', {}).get('href', ''),
name=data.get('display_name'),
email=email,
email=None, # Bitbucket API doesn't return email in this endpoint
)
def _parse_repository(

View File

@@ -4,7 +4,6 @@ from typing import Any, cast
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.protocols.http_client import HTTPClient
from openhands.integrations.service_types import (
BaseGitService,
@@ -23,19 +22,6 @@ class GitHubMixinBase(BaseGitService, HTTPClient):
BASE_URL: str
GRAPHQL_URL: str
@staticmethod
def _resolve_primary_email(emails: list[dict]) -> str | None:
"""Find the primary verified email from a list of GitHub email objects.
GitHub's /user/emails endpoint returns a list of dicts, each with
'email', 'primary', and 'verified' keys. This selects the one marked
as both primary and verified — the email the user considers canonical.
"""
for entry in emails:
if entry.get('primary') and entry.get('verified'):
return entry.get('email')
return None
async def _get_headers(self) -> dict:
"""Retrieve the GH Token from settings store to construct the headers."""
if not self.token:
@@ -121,17 +107,6 @@ class GitHubMixinBase(BaseGitService, HTTPClient):
except httpx.HTTPError as e:
raise self.handle_http_error(e)
async def get_user_emails(self) -> list[dict]:
"""Fetch the authenticated user's email addresses from GitHub.
Calls GET /user/emails which returns a list of email objects, each
containing 'email', 'primary', 'verified', and 'visibility' fields.
Requires the user:email OAuth scope.
"""
url = f'{self.BASE_URL}/user/emails'
response, _ = await self._make_request(url)
return response
async def verify_access(self) -> bool:
url = f'{self.BASE_URL}'
await self._make_request(url)
@@ -141,22 +116,11 @@ class GitHubMixinBase(BaseGitService, HTTPClient):
url = f'{self.BASE_URL}/user'
response, _ = await self._make_request(url)
email = response.get('email')
if email is None:
try:
emails = await self.get_user_emails()
email = self._resolve_primary_email(emails)
except Exception:
logger.warning(
'github:get_user:email_fallback_failed',
exc_info=True,
)
return User(
id=str(response.get('id', '')),
login=cast(str, response.get('login') or ''),
avatar_url=cast(str, response.get('avatar_url') or ''),
company=response.get('company'),
name=response.get('name'),
email=email,
email=response.get('email'),
)

View File

@@ -15,7 +15,6 @@ import re
import uuid
from datetime import datetime, timedelta, timezone
from typing import Annotated
from urllib.parse import urlparse
import base62
import httpx
@@ -41,7 +40,6 @@ from openhands.app_server.config import (
depends_httpx_client,
depends_sandbox_service,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.app_server.sandbox.sandbox_service import SandboxService
from openhands.app_server.services.db_session_injector import set_db_session_keep_open
from openhands.app_server.services.httpx_client_injector import (
@@ -121,7 +119,6 @@ app_conversation_info_service_dependency = depends_app_conversation_info_service
sandbox_service_dependency = depends_sandbox_service()
db_session_dependency = depends_db_session()
httpx_client_dependency = depends_httpx_client()
_RESUME_GRACE_PERIOD = 60
def _filter_conversations_by_age(
@@ -473,7 +470,6 @@ async def get_conversation(
conversation_id: str = Depends(validate_conversation_id),
conversation_store: ConversationStore = Depends(get_conversation_store),
app_conversation_service: AppConversationService = app_conversation_service_dependency,
httpx_client: httpx.AsyncClient = httpx_client_dependency,
) -> ConversationInfo | None:
"""Get a single conversation by ID.
@@ -488,44 +484,6 @@ async def get_conversation(
conversation_uuid
)
if app_conversation:
if (
app_conversation.sandbox_status == SandboxStatus.RUNNING
and app_conversation.execution_status is None
):
# The sandbox is running, but we were unable to determine a status for
# the conversation. It may be that it is still starting, or that the
# conversation has been stopped / deleted.
try:
# Check the server info is available
conversation_url = urlparse(app_conversation.conversation_url)
sandbox_info_url = f'{str(conversation_url.scheme)}://{str(conversation_url.netloc)}/server_info'
response = await httpx_client.get(sandbox_info_url)
response.raise_for_status()
server_info = response.json()
# If the server has not been running long, we consider it still starting
uptime = int(server_info.get('uptime'))
if uptime < _RESUME_GRACE_PERIOD:
app_conversation.sandbox_status = SandboxStatus.STARTING
except Exception:
# The sandbox is marked as RUNNING, but the server is not responding.
# There is a bug in runtime API which means that the server is marked
# as RUNNING before it is actually started. (Primarily affecting resumed
# runtimes) As a temporary work around for this, we mark the server as
# STARTING. If the sandbox is actually in an error state, the API will
# discover this quite quickly and mark the sandbox as ERROR
logger.warning(
'get_sandbox_info_failed',
extra={
'conversation_id': app_conversation.id,
'sandbox_id': app_conversation.sandbox_id,
},
exc_info=True,
stack_info=True,
)
app_conversation.sandbox_status = SandboxStatus.STARTING
return _to_conversation_info(app_conversation)
except (ValueError, TypeError, Exception):
# Not a V1 conversation or service error

View File

@@ -6,8 +6,6 @@
# 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 fastapi import APIRouter, Depends, status
from fastapi.responses import JSONResponse
@@ -32,10 +30,6 @@ from openhands.storage.data_models.settings import Settings
from openhands.storage.secrets.secrets_store import SecretsStore
from openhands.storage.settings.settings_store import SettingsStore
LITE_LLM_API_URL = os.environ.get(
'LITE_LLM_API_URL', 'https://llm-proxy.app.all-hands.dev'
)
app = APIRouter(prefix='/api', dependencies=get_dependencies())
@@ -120,8 +114,10 @@ async def reset_settings() -> JSONResponse:
async def store_llm_settings(
settings: Settings, existing_settings: Settings
settings: Settings, settings_store: SettingsStore
) -> Settings:
existing_settings = await settings_store.load()
# Convert to Settings model and merge with existing settings
if existing_settings:
# Keep existing LLM settings if not provided
@@ -129,9 +125,8 @@ async def store_llm_settings(
settings.llm_api_key = existing_settings.llm_api_key
if settings.llm_model is None:
settings.llm_model = existing_settings.llm_model
# if llm_base_url is missing or empty, set to default as this only happens for "basic" settings
if not settings.llm_base_url:
settings.llm_base_url = LITE_LLM_API_URL
if settings.llm_base_url is None:
settings.llm_base_url = existing_settings.llm_base_url
# Keep search API key if missing or empty
if not settings.search_api_key:
settings.search_api_key = existing_settings.search_api_key
@@ -161,7 +156,7 @@ async def store_settings(
# Convert to Settings model and merge with existing settings
if existing_settings:
settings = await store_llm_settings(settings, existing_settings)
settings = await store_llm_settings(settings, settings_store)
# Keep existing analytics consent if not provided
if settings.user_consents_to_analytics is None:

20
poetry.lock generated
View File

@@ -6235,14 +6235,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
[[package]]
name = "openhands-agent-server"
version = "1.11.1"
version = "1.10.0"
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_agent_server-1.11.1-py3-none-any.whl", hash = "sha256:28e3ca670114c7a936a33f2d193238fbdc75f429c4e0bb99a03b14e6c01663c9"},
{file = "openhands_agent_server-1.11.1.tar.gz", hash = "sha256:06eaf8b8eda4ca05de24751a7d269b22f611328c6cb2b4b91f2486011228b69a"},
{file = "openhands_agent_server-1.10.0-py3-none-any.whl", hash = "sha256:2e21076fff5e7cf9d03a3b011e2c90a6a3a46d2da3f18db9f7553ac413229c22"},
{file = "openhands_agent_server-1.10.0.tar.gz", hash = "sha256:2062da2496a98a6c23201d086f124e02329d6c6d9d1b47be55921c084a29f55a"},
]
[package.dependencies]
@@ -6259,14 +6259,14 @@ wsproto = ">=1.2.0"
[[package]]
name = "openhands-sdk"
version = "1.11.1"
version = "1.10.0"
description = "OpenHands SDK - Core functionality for building AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_sdk-1.11.1-py3-none-any.whl", hash = "sha256:10ee0777286b149db21bdeeadb6d4c57f461da4049a4ba07576e7228b5c76c85"},
{file = "openhands_sdk-1.11.1.tar.gz", hash = "sha256:57f5884d0596a8659b7c0cdbe86ebaa74c810c4e2645fcff45f0113894dd9376"},
{file = "openhands_sdk-1.10.0-py3-none-any.whl", hash = "sha256:5c8875f2a07d7fabe3449914639572bef9003821207cb06aa237a239e964eed5"},
{file = "openhands_sdk-1.10.0.tar.gz", hash = "sha256:93371b1af4532266ad2d225b9d7d3d711c745df31888efe643970673f62bdef9"},
]
[package.dependencies]
@@ -6287,14 +6287,14 @@ boto3 = ["boto3 (>=1.35.0)"]
[[package]]
name = "openhands-tools"
version = "1.11.1"
version = "1.10.0"
description = "OpenHands Tools - Runtime tools for AI agents"
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "openhands_tools-1.11.1-py3-none-any.whl", hash = "sha256:0b64763def90dda5b6545a356a437437c2029ec9bc47a4e6dac5c06dea6a4e77"},
{file = "openhands_tools-1.11.1.tar.gz", hash = "sha256:2a71d2d0619ca631b3b7f5bd741bfdf97f7ebe6f96dc2540f79b9a688a6309fc"},
{file = "openhands_tools-1.10.0-py3-none-any.whl", hash = "sha256:1d5d2d1e34cc4ceb02c0ff1f008b06883ad48a8e7236ab8dd61ece64fbf8e2ed"},
{file = "openhands_tools-1.10.0.tar.gz", hash = "sha256:7ed38cb13545ec2c4a35c26ece725d5b35788d30597db8b1904619c043ec1194"},
]
[package.dependencies]
@@ -14724,4 +14724,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "b9dc8da02728200ec103f498048e95abf2d91ba2c518fb24ba03e83064a1e450"
content-hash = "e95cfdd9837e0a58eaa3864dffcd46f5049ca3dfe48b67acb848f476dd8adce1"

View File

@@ -54,9 +54,9 @@ dependencies = [
"numpy",
"openai==2.8",
"openhands-aci==0.3.2",
"openhands-agent-server==1.11.1",
"openhands-sdk==1.11.1",
"openhands-tools==1.11.1",
"openhands-agent-server==1.10",
"openhands-sdk==1.10",
"openhands-tools==1.10",
"opentelemetry-api>=1.33.1",
"opentelemetry-exporter-otlp-proto-grpc>=1.33.1",
"pathspec>=0.12.1",
@@ -246,9 +246,9 @@ e2b-code-interpreter = { version = "^2.0.0", optional = true }
pybase62 = "^1.0.0"
# V1 dependencies
openhands-sdk = "1.11.1"
openhands-agent-server = "1.11.1"
openhands-tools = "1.11.1"
openhands-sdk = "1.10"
openhands-agent-server = "1.10"
openhands-tools = "1.10"
python-jose = { version = ">=3.3", extras = [ "cryptography" ] }
sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
pg8000 = "^1.31.5"

View File

@@ -709,138 +709,6 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
assert org_repo.owner_type == OwnerType.ORGANIZATION
# ── Bitbucket email fallback tests ──
@pytest.mark.asyncio
async def test_resolve_primary_email_selects_primary_confirmed():
"""_resolve_primary_email returns the email marked primary and confirmed."""
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
emails = [
{'email': 'secondary@example.com', 'is_primary': False, 'is_confirmed': True},
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True},
{
'email': 'unconfirmed@example.com',
'is_primary': False,
'is_confirmed': False,
},
]
result = BitBucketMixinBase._resolve_primary_email(emails)
assert result == 'primary@example.com'
@pytest.mark.asyncio
async def test_resolve_primary_email_returns_none_when_no_primary():
"""_resolve_primary_email returns None when no email is marked primary."""
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
emails = [
{'email': 'a@example.com', 'is_primary': False, 'is_confirmed': True},
{'email': 'b@example.com', 'is_primary': False, 'is_confirmed': True},
]
result = BitBucketMixinBase._resolve_primary_email(emails)
assert result is None
@pytest.mark.asyncio
async def test_resolve_primary_email_returns_none_when_primary_not_confirmed():
"""_resolve_primary_email returns None when primary email is not confirmed."""
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
emails = [
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': False},
{'email': 'other@example.com', 'is_primary': False, 'is_confirmed': True},
]
result = BitBucketMixinBase._resolve_primary_email(emails)
assert result is None
@pytest.mark.asyncio
async def test_resolve_primary_email_returns_none_for_empty_list():
"""_resolve_primary_email returns None for an empty list."""
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
result = BitBucketMixinBase._resolve_primary_email([])
assert result is None
@pytest.mark.asyncio
async def test_get_user_emails():
"""get_user_emails calls /user/emails and returns the values list."""
service = BitBucketService(token=SecretStr('test-token'))
mock_response = {
'values': [
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True},
{
'email': 'secondary@example.com',
'is_primary': False,
'is_confirmed': True,
},
]
}
with patch.object(service, '_make_request', return_value=(mock_response, {})):
emails = await service.get_user_emails()
assert emails == mock_response['values']
@pytest.mark.asyncio
async def test_get_user_falls_back_to_user_emails():
"""get_user calls /user/emails to resolve email (Bitbucket /user never returns email)."""
service = BitBucketService(token=SecretStr('test-token'))
mock_user_response = {
'account_id': '123',
'username': 'testuser',
'display_name': 'Test User',
'links': {'avatar': {'href': 'https://example.com/avatar.jpg'}},
}
mock_emails = [
{'email': 'secondary@example.com', 'is_primary': False, 'is_confirmed': True},
{'email': 'primary@example.com', 'is_primary': True, 'is_confirmed': True},
]
with (
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
patch.object(service, 'get_user_emails', return_value=mock_emails),
):
user = await service.get_user()
assert user.email == 'primary@example.com'
@pytest.mark.asyncio
async def test_get_user_handles_user_emails_api_failure():
"""get_user handles /user/emails failure gracefully — email stays None."""
service = BitBucketService(token=SecretStr('test-token'))
mock_user_response = {
'account_id': '123',
'username': 'testuser',
'display_name': 'Test User',
'links': {'avatar': {'href': 'https://example.com/avatar.jpg'}},
}
with (
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
patch.object(
service,
'get_user_emails',
side_effect=Exception('API Error'),
),
):
user = await service.get_user()
# Email should remain None — no crash
assert user.email is None
assert user.login == 'testuser'
assert user.name == 'Test User'
# Setup.py Bitbucket Token Tests
@patch('openhands.core.setup.call_async_from_sync')
@patch('openhands.core.setup.get_file_store')

View File

@@ -405,153 +405,6 @@ async def test_github_service_graphql_url_enterprise_server():
assert actual_url == 'https://github.enterprise.com/api/graphql'
@pytest.mark.asyncio
async def test_resolve_primary_email_selects_primary_verified():
"""_resolve_primary_email returns the email marked primary and verified."""
from openhands.integrations.github.service.base import GitHubMixinBase
emails = [
{'email': 'secondary@example.com', 'primary': False, 'verified': True},
{'email': 'primary@example.com', 'primary': True, 'verified': True},
{'email': 'unverified@example.com', 'primary': False, 'verified': False},
]
result = GitHubMixinBase._resolve_primary_email(emails)
assert result == 'primary@example.com'
@pytest.mark.asyncio
async def test_resolve_primary_email_returns_none_when_no_primary():
"""_resolve_primary_email returns None when no email is marked primary."""
from openhands.integrations.github.service.base import GitHubMixinBase
emails = [
{'email': 'a@example.com', 'primary': False, 'verified': True},
{'email': 'b@example.com', 'primary': False, 'verified': True},
]
result = GitHubMixinBase._resolve_primary_email(emails)
assert result is None
@pytest.mark.asyncio
async def test_resolve_primary_email_returns_none_when_primary_not_verified():
"""_resolve_primary_email returns None when primary email is not verified."""
from openhands.integrations.github.service.base import GitHubMixinBase
emails = [
{'email': 'primary@example.com', 'primary': True, 'verified': False},
{'email': 'other@example.com', 'primary': False, 'verified': True},
]
result = GitHubMixinBase._resolve_primary_email(emails)
assert result is None
@pytest.mark.asyncio
async def test_resolve_primary_email_returns_none_for_empty_list():
"""_resolve_primary_email returns None for an empty list."""
from openhands.integrations.github.service.base import GitHubMixinBase
result = GitHubMixinBase._resolve_primary_email([])
assert result is None
@pytest.mark.asyncio
async def test_get_user_emails():
"""get_user_emails calls /user/emails and returns the response."""
service = GitHubService(user_id=None, token=SecretStr('test-token'))
mock_emails = [
{'email': 'primary@example.com', 'primary': True, 'verified': True},
{'email': 'secondary@example.com', 'primary': False, 'verified': True},
]
with patch.object(service, '_make_request', return_value=(mock_emails, {})):
emails = await service.get_user_emails()
assert emails == mock_emails
@pytest.mark.asyncio
async def test_get_user_uses_email_from_user_endpoint():
"""get_user does NOT call /user/emails when /user returns an email."""
service = GitHubService(user_id=None, token=SecretStr('test-token'))
mock_user_response = {
'id': 123,
'login': 'testuser',
'avatar_url': 'https://example.com/avatar.jpg',
'company': 'TestCo',
'name': 'Test User',
'email': 'user@example.com',
}
with patch.object(
service, '_make_request', return_value=(mock_user_response, {})
) as mock_request:
user = await service.get_user()
assert user.email == 'user@example.com'
# Only one call to _make_request (/user), no /user/emails call
mock_request.assert_called_once()
@pytest.mark.asyncio
async def test_get_user_falls_back_to_user_emails():
"""get_user calls /user/emails when /user returns null email."""
service = GitHubService(user_id=None, token=SecretStr('test-token'))
mock_user_response = {
'id': 123,
'login': 'testuser',
'avatar_url': 'https://example.com/avatar.jpg',
'company': 'TestCo',
'name': 'Test User',
'email': None,
}
mock_emails = [
{'email': 'secondary@example.com', 'primary': False, 'verified': True},
{'email': 'primary@example.com', 'primary': True, 'verified': True},
]
with (
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
patch.object(service, 'get_user_emails', return_value=mock_emails),
):
user = await service.get_user()
assert user.email == 'primary@example.com'
@pytest.mark.asyncio
async def test_get_user_handles_user_emails_api_failure():
"""get_user handles /user/emails failure gracefully — email stays None."""
service = GitHubService(user_id=None, token=SecretStr('test-token'))
mock_user_response = {
'id': 123,
'login': 'testuser',
'avatar_url': 'https://example.com/avatar.jpg',
'company': None,
'name': 'Test User',
'email': None,
}
with (
patch.object(service, '_make_request', return_value=(mock_user_response, {})),
patch.object(
service,
'get_user_emails',
side_effect=Exception('API Error'),
),
):
user = await service.get_user()
# Email should remain None — no crash
assert user.email is None
assert user.login == 'testuser'
assert user.name == 'Test User'
@pytest.mark.asyncio
async def test_github_service_graphql_url_github_com():
"""Test that GraphQL URL is correctly constructed for GitHub.com."""

View File

@@ -0,0 +1,388 @@
import os
import tempfile
import time
from openhands.core.logger import openhands_logger as logger
from openhands.events.action import CmdRunAction
from openhands.runtime.utils.bash import BashCommandStatus, BashSession
from openhands.runtime.utils.bash_constants import TIMEOUT_MESSAGE_TEMPLATE
def get_no_change_timeout_suffix(timeout_seconds):
"""Helper function to generate the expected no-change timeout suffix."""
return (
f'\n[The command has no new output after {timeout_seconds} seconds. '
f'{TIMEOUT_MESSAGE_TEMPLATE}]'
)
def test_session_initialization():
# Test with custom working directory
with tempfile.TemporaryDirectory() as temp_dir:
session = BashSession(work_dir=temp_dir)
session.initialize()
obs = session.execute(CmdRunAction('pwd'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert temp_dir in obs.content
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
session.close()
# Test with custom username
session = BashSession(work_dir=os.getcwd(), username='nobody')
session.initialize()
assert 'openhands-nobody' in session.session.name
session.close()
def test_cwd_property(tmp_path):
session = BashSession(work_dir=tmp_path)
session.initialize()
# Change directory and verify pwd updates
random_dir = tmp_path / 'random'
random_dir.mkdir()
session.execute(CmdRunAction(f'cd {random_dir}'))
assert session.cwd == str(random_dir)
session.close()
def test_basic_command():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Test simple command
obs = session.execute(CmdRunAction("echo 'hello world'"))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'hello world' in obs.content
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == 0
assert session.prev_status == BashCommandStatus.COMPLETED
# Test command with error
obs = session.execute(CmdRunAction('nonexistent_command'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == 127
assert 'nonexistent_command: command not found' in obs.content
assert obs.metadata.suffix == '\n[The command completed with exit code 127.]'
assert obs.metadata.prefix == ''
assert session.prev_status == BashCommandStatus.COMPLETED
# Test multiple commands in sequence
obs = session.execute(CmdRunAction('echo "first" && echo "second" && echo "third"'))
assert 'first' in obs.content
assert 'second' in obs.content
assert 'third' in obs.content
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == 0
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()
def test_long_running_command_follow_by_execute():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Test command that produces output slowly
obs = session.execute(
CmdRunAction('for i in {1..3}; do echo $i; sleep 3; done', blocking=False)
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '1' in obs.content # First number should appear before timeout
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.prefix == ''
# Continue watching output
obs = session.execute(CmdRunAction('', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '2' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Test command that produces no output
obs = session.execute(CmdRunAction('sleep 15'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '3' not in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert 'The previous command is still running' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
time.sleep(3)
# Run it again, this time it should produce output
obs = session.execute(CmdRunAction('sleep 15'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert '3' in obs.content
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
assert 'The previous command is still running' in obs.metadata.suffix
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
session.close()
def test_interactive_command():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=3)
session.initialize()
# Test interactive command with blocking=True
obs = session.execute(
CmdRunAction(
'read -p \'Enter name: \' name && echo "Hello $name"',
)
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Enter name:' in obs.content
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == ''
# Send input
obs = session.execute(CmdRunAction('John', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Hello John' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
assert session.prev_status == BashCommandStatus.COMPLETED
# Test multiline command input
obs = session.execute(CmdRunAction('cat << EOF'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == ''
obs = session.execute(CmdRunAction('line 1', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('line 2', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert obs.metadata.suffix == get_no_change_timeout_suffix(3)
assert obs.metadata.prefix == '[Below is the output of the previous command.]\n'
obs = session.execute(CmdRunAction('EOF', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'line 1' in obs.content and 'line 2' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert obs.metadata.prefix == ''
session.close()
def test_ctrl_c():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Start infinite loop
obs = session.execute(
CmdRunAction("while true; do echo 'looping'; sleep 3; done"),
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'looping' in obs.content
assert obs.metadata.suffix == get_no_change_timeout_suffix(2)
assert obs.metadata.prefix == ''
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Send Ctrl+C
obs = session.execute(CmdRunAction('C-c', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Check that the process was interrupted (exit code can be 1 or 130 depending on the shell/OS)
assert obs.metadata.exit_code in (
1,
130,
) # Accept both common exit codes for interrupted processes
assert 'CTRL+C was sent' in obs.metadata.suffix
assert obs.metadata.prefix == ''
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()
def test_empty_command_errors():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Test empty command without previous command
obs = session.execute(CmdRunAction(''))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert obs.content == 'ERROR: No previous running command to retrieve logs from.'
assert obs.metadata.exit_code == -1
assert obs.metadata.prefix == ''
assert obs.metadata.suffix == ''
assert session.prev_status is None
session.close()
def test_command_output_continuation():
"""Test that we can continue to get output from a long-running command.
This test has been modified to be more robust against timing issues.
"""
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=1)
session.initialize()
# Start a command that produces output slowly but with longer sleep time
# to ensure we hit the timeout
obs = session.execute(CmdRunAction('for i in {1..5}; do echo $i; sleep 2; done'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Check if the command completed immediately or timed out
if session.prev_status == BashCommandStatus.COMPLETED:
# If the command completed immediately, verify we got all the output
logger.info('Command completed immediately', extra={'msg_type': 'TEST_INFO'})
assert '1' in obs.content
assert '2' in obs.content
assert '3' in obs.content
assert '4' in obs.content
assert '5' in obs.content
assert '[The command completed with exit code 0.]' in obs.metadata.suffix
else:
# If the command timed out, verify we got the timeout message
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
assert '1' in obs.content
assert '[The command has no new output after 1 seconds.' in obs.metadata.suffix
# Continue getting output until we see all numbers
numbers_seen = set()
for i in range(1, 6):
if str(i) in obs.content:
numbers_seen.add(i)
# We need to see numbers 2-5 and then the command completion
while (
len(numbers_seen) < 5 or session.prev_status != BashCommandStatus.COMPLETED
):
obs = session.execute(CmdRunAction('', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
# Check for numbers in the output
for i in range(1, 6):
if str(i) in obs.content and i not in numbers_seen:
numbers_seen.add(i)
logger.info(
f'Found number {i} in output', extra={'msg_type': 'TEST_INFO'}
)
# Check if the command has completed
if session.prev_status == BashCommandStatus.COMPLETED:
assert (
'[The command completed with exit code 0.]' in obs.metadata.suffix
)
break
else:
assert (
'[The command has no new output after 1 seconds.'
in obs.metadata.suffix
)
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Verify we've seen all numbers
assert numbers_seen == {1, 2, 3, 4, 5}, (
f'Expected to see numbers 1-5, but saw {numbers_seen}'
)
# Verify the command completed
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()
def test_long_output():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Generate a long output that may exceed buffer size
obs = session.execute(CmdRunAction('for i in {1..5000}; do echo "Line $i"; done'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Line 1' in obs.content
assert 'Line 5000' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.prefix == ''
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
session.close()
def test_long_output_exceed_history_limit():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Generate a long output that may exceed buffer size
obs = session.execute(CmdRunAction('for i in {1..50000}; do echo "Line $i"; done'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Previous command outputs are truncated' in obs.metadata.prefix
assert 'Line 40000' in obs.content
assert 'Line 50000' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
session.close()
def test_multiline_command():
session = BashSession(work_dir=os.getcwd())
session.initialize()
# Test multiline command with PS2 prompt disabled
obs = session.execute(
CmdRunAction("""if true; then
echo "inside if"
fi""")
)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'inside if' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.prefix == ''
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
session.close()
def test_python_interactive_input():
session = BashSession(work_dir=os.getcwd(), no_change_timeout_seconds=2)
session.initialize()
# Test Python program that asks for input - properly escaped for bash
python_script = """name = input('Enter your name: '); age = input('Enter your age: '); print(f'Hello {name}, you are {age} years old')"""
# Start Python with the interactive script
obs = session.execute(CmdRunAction(f'python3 -c "{python_script}"'))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Enter your name:' in obs.content
assert obs.metadata.exit_code == -1 # -1 indicates command is still running
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Send first input (name)
obs = session.execute(CmdRunAction('Alice', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Enter your age:' in obs.content
assert obs.metadata.exit_code == -1
assert session.prev_status == BashCommandStatus.NO_CHANGE_TIMEOUT
# Send second input (age)
obs = session.execute(CmdRunAction('25', is_input=True))
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert 'Hello Alice, you are 25 years old' in obs.content
assert obs.metadata.exit_code == 0
assert obs.metadata.suffix == '\n[The command completed with exit code 0.]'
assert session.prev_status == BashCommandStatus.COMPLETED
session.close()

View File

@@ -3,7 +3,6 @@ from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import httpx
import pytest
from fastapi import status
from fastapi.responses import JSONResponse
@@ -13,7 +12,6 @@ from openhands.app_server.app_conversation.app_conversation_info_service import
)
from openhands.app_server.app_conversation.app_conversation_models import (
AgentType,
AppConversation,
AppConversationInfo,
AppConversationPage,
AppConversationStartRequest,
@@ -23,10 +21,8 @@ from openhands.app_server.app_conversation.app_conversation_models import (
from openhands.app_server.app_conversation.app_conversation_service import (
AppConversationService,
)
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
from openhands.microagent.microagent import KnowledgeMicroagent, RepoMicroagent
from openhands.microagent.types import MicroagentMetadata, MicroagentType
from openhands.server.data_models.conversation_info import ConversationStatus
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
@@ -36,9 +32,7 @@ from openhands.server.routes.conversation import (
get_microagents,
)
from openhands.server.routes.manage_conversations import (
_RESUME_GRACE_PERIOD,
UpdateConversationRequest,
get_conversation,
search_conversations,
update_conversation,
)
@@ -1465,102 +1459,3 @@ async def test_search_conversations_include_sub_conversations_with_other_filters
assert call_kwargs.get('include_sub_conversations') is True
assert call_kwargs.get('page_id') == 'test_v1_page_id'
assert call_kwargs.get('limit') == 50
@pytest.mark.asyncio
@pytest.mark.parametrize(
'sandbox_status,execution_status,server_uptime,server_error,expected_status,should_call_server',
[
# RUNNING sandbox, no execution_status, server responds within grace period -> STARTING
(SandboxStatus.RUNNING, None, 30, None, ConversationStatus.STARTING, True),
# RUNNING sandbox, no execution_status, server responds past grace period -> RUNNING
(
SandboxStatus.RUNNING,
None,
_RESUME_GRACE_PERIOD + 10,
None,
ConversationStatus.RUNNING,
True,
),
# RUNNING sandbox, no execution_status, server unresponsive -> STARTING
(
SandboxStatus.RUNNING,
None,
None,
httpx.ConnectError('Connection refused'),
ConversationStatus.STARTING,
True,
),
# RUNNING sandbox, WITH execution_status -> RUNNING, skip server check
(SandboxStatus.RUNNING, 'IDLE', None, None, ConversationStatus.RUNNING, False),
# Non-RUNNING sandbox -> STARTING, skip server check
(SandboxStatus.STARTING, None, None, None, ConversationStatus.STARTING, False),
],
ids=[
'running_no_exec_status_within_grace_period',
'running_no_exec_status_past_grace_period',
'running_no_exec_status_server_unresponsive',
'running_with_exec_status_skips_check',
'non_running_skips_check',
],
)
async def test_get_conversation_resume_status_handling(
sandbox_status,
execution_status,
server_uptime,
server_error,
expected_status,
should_call_server,
):
"""Test get_conversation handles resume status correctly for various scenarios."""
from openhands.sdk.conversation.state import ConversationExecutionStatus
conversation_id = uuid4()
# Convert string execution_status to enum if provided
exec_status = None
if execution_status == 'IDLE':
exec_status = ConversationExecutionStatus.IDLE
mock_app_conversation = AppConversation(
id=conversation_id,
created_by_user_id='test_user',
sandbox_id='test_sandbox',
sandbox_status=sandbox_status,
execution_status=exec_status,
conversation_url='https://sandbox.example.com/conversation',
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_app_conversation_service = AsyncMock(spec=AppConversationService)
mock_app_conversation_service.get_app_conversation.return_value = (
mock_app_conversation
)
mock_conversation_store = AsyncMock(spec=ConversationStore)
mock_httpx_client = AsyncMock(spec=httpx.AsyncClient)
if server_error:
mock_httpx_client.get.side_effect = server_error
elif server_uptime is not None:
mock_response = MagicMock()
mock_response.json.return_value = {'uptime': server_uptime}
mock_response.raise_for_status = MagicMock()
mock_httpx_client.get.return_value = mock_response
result = await get_conversation(
conversation_id=str(conversation_id),
conversation_store=mock_conversation_store,
app_conversation_service=mock_app_conversation_service,
httpx_client=mock_httpx_client,
)
assert result is not None
assert result.status == expected_status
if should_call_server:
mock_httpx_client.get.assert_called_once_with(
'https://sandbox.example.com/server_info'
)
else:
mock_httpx_client.get.assert_not_called()

View File

@@ -149,10 +149,11 @@ async def test_store_llm_settings_new_settings():
llm_base_url='https://api.example.com',
)
# No existing settings
existing_settings = None
# Mock the settings store
mock_store = MagicMock()
mock_store.load = AsyncMock(return_value=None) # No existing settings
result = await store_llm_settings(settings, existing_settings)
result = await store_llm_settings(settings, mock_store)
# Should return settings with the provided values
assert result.llm_model == 'gpt-4'
@@ -169,6 +170,9 @@ async def test_store_llm_settings_update_existing():
llm_base_url='https://new.example.com',
)
# Mock the settings store
mock_store = MagicMock()
# Create existing settings
existing_settings = Settings(
llm_model='gpt-3.5',
@@ -176,7 +180,9 @@ async def test_store_llm_settings_update_existing():
llm_base_url='https://old.example.com',
)
result = await store_llm_settings(settings, existing_settings)
mock_store.load = AsyncMock(return_value=existing_settings)
result = await store_llm_settings(settings, mock_store)
# Should return settings with the updated values
assert result.llm_model == 'gpt-4'
@@ -186,16 +192,14 @@ async def test_store_llm_settings_update_existing():
@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, it gets set to the default
LiteLLM proxy URL. This is intentional behavior for "basic" settings where users
don't specify a custom base URL.
"""
"""Test store_llm_settings with partial update."""
settings = Settings(
llm_model='gpt-4' # Only updating model
)
# Mock the settings store
mock_store = MagicMock()
# Create existing settings
existing_settings = Settings(
llm_model='gpt-3.5',
@@ -203,19 +207,15 @@ async def test_store_llm_settings_partial_update():
llm_base_url='https://existing.example.com',
)
result = await store_llm_settings(settings, existing_settings)
mock_store.load = AsyncMock(return_value=existing_settings)
# Should return settings with updated model but keep API key
result = await store_llm_settings(settings, mock_store)
# Should return settings with updated model but keep other values
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'
# When llm_base_url is not provided, it defaults to the LiteLLM proxy URL
import os
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
assert result.llm_base_url == 'https://existing.example.com'
# Tests for store_provider_tokens

1432
uv.lock generated

File diff suppressed because it is too large Load Diff