mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d4f7f07d5d | |||
| a34dc949ce | |||
| d6b8d80026 | |||
| 1e6a92b454 | |||
| b4a3e5db2f | |||
| 80e4fe1226 | |||
| f9d553d0bb | |||
| f6f6c1ab25 | |||
| c511a89426 | |||
| 1f82ff04d9 | |||
| eec17311c7 |
@@ -48,15 +48,18 @@ from server.routes.orgs import org_router # noqa: E402
|
||||
from server.routes.readiness import readiness_router # noqa: E402
|
||||
from server.routes.user import saas_user_router # noqa: E402
|
||||
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
|
||||
from server.routes.verified_models import ( # noqa: E402
|
||||
api_router as verified_models_router,
|
||||
)
|
||||
from server.sharing.shared_conversation_router import ( # noqa: E402
|
||||
router as shared_conversation_router,
|
||||
)
|
||||
from server.sharing.shared_event_router import ( # noqa: E402
|
||||
router as shared_event_router,
|
||||
)
|
||||
from server.verified_models.verified_model_router import ( # noqa: E402
|
||||
api_router as verified_models_router,
|
||||
)
|
||||
from server.verified_models.verified_model_router import ( # noqa: E402
|
||||
override_llm_models_dependency,
|
||||
)
|
||||
|
||||
from openhands.server.app import app as base_app # noqa: E402
|
||||
from openhands.server.listen_socket import sio # noqa: E402
|
||||
@@ -113,6 +116,11 @@ base_app.include_router(org_router) # Add routes for organization management
|
||||
base_app.include_router(
|
||||
verified_models_router
|
||||
) # Add routes for verified models management
|
||||
|
||||
# Override the default LLM models implementation with SaaS version
|
||||
# This must happen after all routers are included
|
||||
override_llm_models_dependency(base_app)
|
||||
|
||||
base_app.include_router(invitation_router) # Add routes for org invitation management
|
||||
base_app.include_router(invitation_accept_router) # Add route for accepting invitations
|
||||
add_github_proxy_routes(base_app)
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field, SecretStr, StringConstraints
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
EmailStr,
|
||||
Field,
|
||||
SecretStr,
|
||||
StringConstraints,
|
||||
field_validator,
|
||||
)
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
from storage.role import Role
|
||||
@@ -252,6 +259,115 @@ class OrgUpdate(BaseModel):
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
|
||||
|
||||
class OrgLLMSettingsResponse(BaseModel):
|
||||
"""Response model for organization LLM settings."""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
search_api_key: str | None = None # Masked in response
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool = True
|
||||
condenser_max_size: int | None = None
|
||||
default_max_iterations: int | None = None
|
||||
|
||||
@staticmethod
|
||||
def _mask_key(secret: SecretStr | None) -> str | None:
|
||||
"""Mask an API key, showing only last 4 characters."""
|
||||
if secret is None:
|
||||
return None
|
||||
raw = secret.get_secret_value()
|
||||
if not raw:
|
||||
return None
|
||||
if len(raw) <= 4:
|
||||
return '****'
|
||||
return '****' + raw[-4:]
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org) -> 'OrgLLMSettingsResponse':
|
||||
"""Create response from Org entity."""
|
||||
return cls(
|
||||
default_llm_model=org.default_llm_model,
|
||||
default_llm_base_url=org.default_llm_base_url,
|
||||
search_api_key=cls._mask_key(org.search_api_key),
|
||||
agent=org.agent,
|
||||
confirmation_mode=org.confirmation_mode,
|
||||
security_analyzer=org.security_analyzer,
|
||||
enable_default_condenser=org.enable_default_condenser
|
||||
if org.enable_default_condenser is not None
|
||||
else True,
|
||||
condenser_max_size=org.condenser_max_size,
|
||||
default_max_iterations=org.default_max_iterations,
|
||||
)
|
||||
|
||||
|
||||
class OrgMemberLLMSettings(BaseModel):
|
||||
"""LLM settings to propagate to organization members.
|
||||
|
||||
Field names match OrgMember DB columns.
|
||||
"""
|
||||
|
||||
llm_model: str | None = None
|
||||
llm_base_url: str | None = None
|
||||
max_iterations: int | None = None
|
||||
llm_api_key: str | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
|
||||
|
||||
class OrgLLMSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization LLM settings.
|
||||
|
||||
Field names match Org DB columns exactly.
|
||||
"""
|
||||
|
||||
default_llm_model: str | None = None
|
||||
default_llm_base_url: str | None = None
|
||||
search_api_key: str | None = None
|
||||
agent: str | None = None
|
||||
confirmation_mode: bool | None = None
|
||||
security_analyzer: str | None = None
|
||||
enable_default_condenser: bool | None = None
|
||||
condenser_max_size: int | None = Field(default=None, ge=20)
|
||||
default_max_iterations: int | None = Field(default=None, gt=0)
|
||||
llm_api_key: str | None = None
|
||||
|
||||
def has_updates(self) -> bool:
|
||||
"""Check if any field is set (not None)."""
|
||||
return any(getattr(self, field) is not None for field in self.model_fields)
|
||||
|
||||
def apply_to_org(self, org: Org) -> None:
|
||||
"""Apply non-None settings to the organization model.
|
||||
|
||||
Args:
|
||||
org: Organization entity to update in place
|
||||
"""
|
||||
for field_name in self.model_fields:
|
||||
value = getattr(self, field_name)
|
||||
# Skip llm_api_key - it's only for member propagation, not org-level
|
||||
if value is not None and field_name != 'llm_api_key':
|
||||
setattr(org, field_name, value)
|
||||
|
||||
def get_member_updates(self) -> OrgMemberLLMSettings | None:
|
||||
"""Get updates that need to be propagated to org members.
|
||||
|
||||
Returns:
|
||||
OrgMemberLLMSettings with mapped field values, or None if no member updates needed.
|
||||
Maps: default_llm_model → llm_model, default_llm_base_url → llm_base_url,
|
||||
default_max_iterations → max_iterations, llm_api_key → llm_api_key
|
||||
"""
|
||||
member_settings = OrgMemberLLMSettings(
|
||||
llm_model=self.default_llm_model,
|
||||
llm_base_url=self.default_llm_base_url,
|
||||
max_iterations=self.default_max_iterations,
|
||||
llm_api_key=self.llm_api_key,
|
||||
)
|
||||
return member_settings if member_settings.has_updates() else None
|
||||
|
||||
|
||||
class OrgMemberResponse(BaseModel):
|
||||
"""Response model for a single organization member."""
|
||||
|
||||
@@ -327,3 +443,44 @@ class MeResponse(BaseModel):
|
||||
llm_base_url=member.llm_base_url,
|
||||
status=member.status,
|
||||
)
|
||||
|
||||
|
||||
class OrgAppSettingsResponse(BaseModel):
|
||||
"""Response model for organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool = True
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@classmethod
|
||||
def from_org(cls, org: Org) -> 'OrgAppSettingsResponse':
|
||||
"""Create an OrgAppSettingsResponse from an Org entity.
|
||||
|
||||
Args:
|
||||
org: The organization entity
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse with app settings
|
||||
"""
|
||||
return cls(
|
||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
|
||||
if org.enable_proactive_conversation_starters is not None
|
||||
else True,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
)
|
||||
|
||||
|
||||
class OrgAppSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool | None = None
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@field_validator('max_budget_per_task')
|
||||
@classmethod
|
||||
def validate_max_budget_per_task(cls, v: float | None) -> float | None:
|
||||
if v is not None and v <= 0:
|
||||
raise ValueError('max_budget_per_task must be greater than 0')
|
||||
return v
|
||||
|
||||
@@ -15,9 +15,13 @@ from server.routes.org_models import (
|
||||
LiteLLMIntegrationError,
|
||||
MemberUpdateError,
|
||||
MeResponse,
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgAuthorizationError,
|
||||
OrgCreate,
|
||||
OrgDatabaseError,
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgMemberNotFoundError,
|
||||
OrgMemberPage,
|
||||
OrgMemberResponse,
|
||||
@@ -30,6 +34,14 @@ from server.routes.org_models import (
|
||||
OrphanedUserError,
|
||||
RoleNotFoundError,
|
||||
)
|
||||
from server.services.org_app_settings_service import (
|
||||
OrgAppSettingsService,
|
||||
OrgAppSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_llm_settings_service import (
|
||||
OrgLLMSettingsService,
|
||||
OrgLLMSettingsServiceInjector,
|
||||
)
|
||||
from server.services.org_member_service import OrgMemberService
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
@@ -40,6 +52,13 @@ from openhands.server.user_auth import get_user_id
|
||||
# Initialize API router
|
||||
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
|
||||
|
||||
# Create injector instance and dependency for LLM settings
|
||||
_org_llm_settings_injector = OrgLLMSettingsServiceInjector()
|
||||
org_llm_settings_service_dependency = Depends(_org_llm_settings_injector.depends)
|
||||
# Create injector instance and dependency at module level
|
||||
_org_app_settings_injector = OrgAppSettingsServiceInjector()
|
||||
org_app_settings_service_dependency = Depends(_org_app_settings_injector.depends)
|
||||
|
||||
|
||||
@org_router.get('', response_model=OrgPage)
|
||||
async def list_user_orgs(
|
||||
@@ -201,6 +220,195 @@ async def create_org(
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.VIEW_LLM_SETTINGS))],
|
||||
)
|
||||
async def get_org_llm_settings(
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for the user's current organization.
|
||||
|
||||
This endpoint retrieves the LLM configuration settings for the
|
||||
authenticated user's current organization. All organization members
|
||||
can view these settings.
|
||||
|
||||
Args:
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if not a member of any organization
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if retrieval fails
|
||||
"""
|
||||
try:
|
||||
return await service.get_org_llm_settings()
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error getting organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to retrieve LLM settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/llm',
|
||||
response_model=OrgLLMSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.EDIT_LLM_SETTINGS))],
|
||||
)
|
||||
async def update_org_llm_settings(
|
||||
settings: OrgLLMSettingsUpdate,
|
||||
service: OrgLLMSettingsService = org_llm_settings_service_dependency,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for the user's current organization.
|
||||
|
||||
This endpoint updates the LLM configuration settings for the
|
||||
authenticated user's current organization. Only admins and owners
|
||||
can update these settings.
|
||||
|
||||
Args:
|
||||
settings: The LLM settings to update (only non-None fields are updated)
|
||||
service: OrgLLMSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if not authenticated
|
||||
HTTPException: 403 if user lacks EDIT_LLM_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
try:
|
||||
return await service.update_org_llm_settings(settings)
|
||||
except OrgNotFoundError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(e),
|
||||
)
|
||||
except OrgDatabaseError as e:
|
||||
logger.error(
|
||||
'Database error updating LLM settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Error updating organization LLM settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update LLM settings',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get(
|
||||
'/app',
|
||||
response_model=OrgAppSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
|
||||
)
|
||||
async def get_org_app_settings(
|
||||
service: OrgAppSettingsService = org_app_settings_service_dependency,
|
||||
) -> OrgAppSettingsResponse:
|
||||
"""Get organization app settings for the user's current organization.
|
||||
|
||||
This endpoint retrieves application settings for the authenticated user's
|
||||
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
|
||||
which is granted to all organization members (member, admin, and owner roles).
|
||||
|
||||
Args:
|
||||
service: OrgAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The organization app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
"""
|
||||
try:
|
||||
return await service.get_org_app_settings()
|
||||
except OrgNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Current organization not found',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error retrieving organization app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.post(
|
||||
'/app',
|
||||
response_model=OrgAppSettingsResponse,
|
||||
dependencies=[Depends(require_permission(Permission.MANAGE_APPLICATION_SETTINGS))],
|
||||
)
|
||||
async def update_org_app_settings(
|
||||
update_data: OrgAppSettingsUpdate,
|
||||
service: OrgAppSettingsService = org_app_settings_service_dependency,
|
||||
) -> OrgAppSettingsResponse:
|
||||
"""Update organization app settings for the user's current organization.
|
||||
|
||||
This endpoint updates application settings for the authenticated user's
|
||||
current organization. Access requires the MANAGE_APPLICATION_SETTINGS permission,
|
||||
which is granted to all organization members (member, admin, and owner roles).
|
||||
|
||||
Args:
|
||||
update_data: App settings update data
|
||||
service: OrgAppSettingsService (injected by dependency)
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The updated organization app settings
|
||||
|
||||
Raises:
|
||||
HTTPException: 401 if user is not authenticated
|
||||
HTTPException: 403 if user lacks MANAGE_APPLICATION_SETTINGS permission
|
||||
HTTPException: 404 if current organization not found
|
||||
HTTPException: 422 if validation errors occur (handled by FastAPI)
|
||||
HTTPException: 500 if update fails
|
||||
"""
|
||||
try:
|
||||
return await service.update_org_app_settings(update_data)
|
||||
except OrgNotFoundError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Current organization not found',
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
'Unexpected error updating organization app settings',
|
||||
extra={'error': str(e)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='An unexpected error occurred',
|
||||
)
|
||||
|
||||
|
||||
@org_router.get('/{org_id}', response_model=OrgResponse, status_code=status.HTTP_200_OK)
|
||||
async def get_org(
|
||||
org_id: UUID,
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""API routes for managing verified LLM models (admin only)."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, field_validator
|
||||
from server.email_validation import get_admin_user_id
|
||||
from storage.verified_model_store import VerifiedModelStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
|
||||
|
||||
|
||||
class VerifiedModelCreate(BaseModel):
|
||||
model_name: str
|
||||
provider: str
|
||||
is_enabled: bool = True
|
||||
|
||||
@field_validator('model_name')
|
||||
@classmethod
|
||||
def validate_model_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v or len(v) > 255:
|
||||
raise ValueError('model_name must be 1-255 characters')
|
||||
return v
|
||||
|
||||
@field_validator('provider')
|
||||
@classmethod
|
||||
def validate_provider(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v or len(v) > 100:
|
||||
raise ValueError('provider must be 1-100 characters')
|
||||
return v
|
||||
|
||||
|
||||
class VerifiedModelUpdate(BaseModel):
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class VerifiedModelResponse(BaseModel):
|
||||
id: int
|
||||
model_name: str
|
||||
provider: str
|
||||
is_enabled: bool
|
||||
|
||||
|
||||
class VerifiedModelPage(BaseModel):
|
||||
"""Paginated response model for verified model list."""
|
||||
|
||||
items: list[VerifiedModelResponse]
|
||||
next_page_id: str | None = None
|
||||
|
||||
|
||||
def _to_response(model) -> VerifiedModelResponse:
|
||||
return VerifiedModelResponse(
|
||||
id=model.id,
|
||||
model_name=model.model_name,
|
||||
provider=model.provider,
|
||||
is_enabled=model.is_enabled,
|
||||
)
|
||||
|
||||
|
||||
@api_router.get('', response_model=VerifiedModelPage)
|
||||
async def list_verified_models(
|
||||
provider: str | None = None,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int, Query(title='The max number of results in the page', gt=0, le=100)
|
||||
] = 100,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
):
|
||||
"""List all verified models, optionally filtered by provider."""
|
||||
try:
|
||||
if provider:
|
||||
all_models = VerifiedModelStore.get_models_by_provider(provider)
|
||||
else:
|
||||
all_models = VerifiedModelStore.get_all_models()
|
||||
|
||||
try:
|
||||
offset = int(page_id) if page_id else 0
|
||||
except ValueError:
|
||||
offset = 0
|
||||
page = all_models[offset : offset + limit + 1]
|
||||
has_more = len(page) > limit
|
||||
if has_more:
|
||||
page = page[:limit]
|
||||
|
||||
return VerifiedModelPage(
|
||||
items=[_to_response(m) for m in page],
|
||||
next_page_id=str(offset + limit) if has_more else None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error listing verified models')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to list verified models',
|
||||
)
|
||||
|
||||
|
||||
@api_router.post('', response_model=VerifiedModelResponse, status_code=201)
|
||||
async def create_verified_model(
|
||||
data: VerifiedModelCreate,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
):
|
||||
"""Create a new verified model."""
|
||||
try:
|
||||
model = VerifiedModelStore.create_model(
|
||||
model_name=data.model_name,
|
||||
provider=data.provider,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
return _to_response(model)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=str(e),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Error creating verified model')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to create verified model',
|
||||
)
|
||||
|
||||
|
||||
@api_router.put('/{provider}/{model_name:path}', response_model=VerifiedModelResponse)
|
||||
async def update_verified_model(
|
||||
provider: str,
|
||||
model_name: str,
|
||||
data: VerifiedModelUpdate,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
):
|
||||
"""Update a verified model by provider and model name."""
|
||||
try:
|
||||
model = VerifiedModelStore.update_model(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Model {provider}/{model_name} not found',
|
||||
)
|
||||
return _to_response(model)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(f'Error updating verified model: {provider}/{model_name}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to update verified model',
|
||||
)
|
||||
|
||||
|
||||
@api_router.delete('/{provider}/{model_name:path}')
|
||||
async def delete_verified_model(
|
||||
provider: str,
|
||||
model_name: str,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
):
|
||||
"""Delete a verified model by provider and model name."""
|
||||
try:
|
||||
success = VerifiedModelStore.delete_model(
|
||||
model_name=model_name, provider=provider
|
||||
)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Model {provider}/{model_name} not found',
|
||||
)
|
||||
return {'message': f'Model {provider}/{model_name} deleted'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception(f'Error deleting verified model: {provider}/{model_name}')
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Failed to delete verified model',
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Service class for managing organization app settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.org_models import (
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from storage.org_app_settings_store import OrgAppSettingsStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgAppSettingsService:
|
||||
"""Service for organization app settings with injected dependencies."""
|
||||
|
||||
store: OrgAppSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_org_app_settings(self) -> OrgAppSettingsResponse:
|
||||
"""Get organization app settings.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The organization's app settings
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If current organization is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
|
||||
logger.info(
|
||||
'Getting organization app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
|
||||
return OrgAppSettingsResponse.from_org(org)
|
||||
|
||||
async def update_org_app_settings(
|
||||
self,
|
||||
update_data: OrgAppSettingsUpdate,
|
||||
) -> OrgAppSettingsResponse:
|
||||
"""Update organization app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
OrgAppSettingsResponse: The updated organization's app settings
|
||||
|
||||
Raises:
|
||||
OrgNotFoundError: If current organization is not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
|
||||
logger.info(
|
||||
'Updating organization app settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Get current org first
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('current')
|
||||
|
||||
# Check if any fields are provided
|
||||
update_dict = update_data.model_dump(exclude_unset=True)
|
||||
|
||||
if not update_dict:
|
||||
# No fields to update, just return current settings
|
||||
logger.info(
|
||||
'No fields to update in app settings',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
return OrgAppSettingsResponse.from_org(org)
|
||||
|
||||
updated_org = await self.store.update_org_app_settings(
|
||||
org_id=org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not updated_org:
|
||||
raise OrgNotFoundError('current')
|
||||
|
||||
logger.info(
|
||||
'Organization app settings updated successfully',
|
||||
extra={'user_id': user_id, 'updated_fields': list(update_dict.keys())},
|
||||
)
|
||||
|
||||
return OrgAppSettingsResponse.from_org(updated_org)
|
||||
|
||||
|
||||
class OrgAppSettingsServiceInjector(Injector[OrgAppSettingsService]):
|
||||
"""Injector that composes store and user_context for OrgAppSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[OrgAppSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = OrgAppSettingsStore(db_session=db_session)
|
||||
yield OrgAppSettingsService(store=store, user_context=user_context)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Service class for managing organization LLM settings.
|
||||
|
||||
Separates business logic from route handlers.
|
||||
Uses dependency injection for db_session and user_context.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from fastapi import Request
|
||||
from server.routes.org_models import (
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from storage.org_llm_settings_store import OrgLLMSettingsStore
|
||||
|
||||
from openhands.app_server.services.injector import Injector, InjectorState
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsService:
|
||||
"""Service for org LLM settings with injected dependencies."""
|
||||
|
||||
store: OrgLLMSettingsStore
|
||||
user_context: UserContext
|
||||
|
||||
async def get_org_llm_settings(self) -> OrgLLMSettingsResponse:
|
||||
"""Get LLM settings for user's current organization.
|
||||
|
||||
User ID is obtained from the injected user_context.
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Getting organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(org)
|
||||
|
||||
async def update_org_llm_settings(
|
||||
self,
|
||||
update_data: OrgLLMSettingsUpdate,
|
||||
) -> OrgLLMSettingsResponse:
|
||||
"""Update LLM settings for user's current organization.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
User ID is obtained from the injected user_context.
|
||||
Session auto-commits at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
update_data: The update data from the request
|
||||
|
||||
Returns:
|
||||
OrgLLMSettingsResponse: The updated organization's LLM settings
|
||||
|
||||
Raises:
|
||||
ValueError: If user is not authenticated
|
||||
OrgNotFoundError: If current organization not found
|
||||
"""
|
||||
user_id = await self.user_context.get_user_id()
|
||||
if not user_id:
|
||||
raise ValueError('User is not authenticated')
|
||||
|
||||
logger.info(
|
||||
'Updating organization LLM settings',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Check if any fields are provided
|
||||
if not update_data.has_updates():
|
||||
# No fields to update, just return current settings
|
||||
return await self.get_org_llm_settings()
|
||||
|
||||
# Get user's current org first
|
||||
org = await self.store.get_current_org_by_user_id(user_id)
|
||||
if not org:
|
||||
raise OrgNotFoundError('No current organization')
|
||||
|
||||
# Update the org LLM settings
|
||||
updated_org = await self.store.update_org_llm_settings(
|
||||
org_id=org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
if not updated_org:
|
||||
raise OrgNotFoundError(str(org.id))
|
||||
|
||||
logger.info(
|
||||
'Organization LLM settings updated successfully',
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
return OrgLLMSettingsResponse.from_org(updated_org)
|
||||
|
||||
|
||||
class OrgLLMSettingsServiceInjector(Injector[OrgLLMSettingsService]):
|
||||
"""Injector that composes store and user_context for OrgLLMSettingsService."""
|
||||
|
||||
async def inject(
|
||||
self, state: InjectorState, request: Request | None = None
|
||||
) -> AsyncGenerator[OrgLLMSettingsService, None]:
|
||||
# Local imports to avoid circular dependencies
|
||||
from openhands.app_server.config import get_db_session, get_user_context
|
||||
|
||||
async with (
|
||||
get_user_context(state, request) as user_context,
|
||||
get_db_session(state, request) as db_session,
|
||||
):
|
||||
store = OrgLLMSettingsStore(db_session=db_session)
|
||||
yield OrgLLMSettingsService(store=store, user_context=user_context)
|
||||
@@ -0,0 +1,33 @@
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, StringConstraints
|
||||
|
||||
|
||||
class VerifiedModelCreate(BaseModel):
|
||||
model_name: Annotated[
|
||||
str,
|
||||
StringConstraints(max_length=255),
|
||||
]
|
||||
provider: Annotated[
|
||||
str,
|
||||
StringConstraints(max_length=100),
|
||||
]
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class VerifiedModel(VerifiedModelCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class VerifiedModelUpdate(BaseModel):
|
||||
is_enabled: bool | None = None
|
||||
|
||||
|
||||
class VerifiedModelPage(BaseModel):
|
||||
"""Paginated response model for verified model list."""
|
||||
|
||||
items: list[VerifiedModel]
|
||||
next_page_id: str | None = None
|
||||
@@ -0,0 +1,143 @@
|
||||
"""API routes for managing verified LLM models (admin only)."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from server.email_validation import get_admin_user_id
|
||||
from server.verified_models.verified_model_models import (
|
||||
VerifiedModel,
|
||||
VerifiedModelCreate,
|
||||
VerifiedModelPage,
|
||||
VerifiedModelUpdate,
|
||||
)
|
||||
from server.verified_models.verified_model_service import (
|
||||
VerifiedModelService,
|
||||
verified_model_store_dependency,
|
||||
)
|
||||
|
||||
from openhands.app_server.config import get_db_session
|
||||
from openhands.server.routes import public
|
||||
from openhands.utils.llm import get_supported_llm_models
|
||||
|
||||
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
|
||||
|
||||
|
||||
@api_router.get('')
|
||||
async def search_verified_models(
|
||||
provider: str | None = None,
|
||||
page_id: Annotated[
|
||||
str | None,
|
||||
Query(title='Optional next_page_id from the previously returned page'),
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int, Query(title='The max number of results in the page', gt=0, le=100)
|
||||
] = 100,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> VerifiedModelPage:
|
||||
"""List all verified models, optionally filtered by provider."""
|
||||
# Use SQL-level filtering and pagination
|
||||
result = await verified_model_service.search_verified_models(
|
||||
provider=provider,
|
||||
enabled_only=False, # Admin sees all models including disabled
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@api_router.post('', status_code=201)
|
||||
async def create_verified_model(
|
||||
data: VerifiedModelCreate,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> VerifiedModel:
|
||||
"""Create a new verified model."""
|
||||
try:
|
||||
model = await verified_model_service.create_verified_model(
|
||||
model_name=data.model_name,
|
||||
provider=data.provider,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
return model
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(ex),
|
||||
)
|
||||
|
||||
|
||||
@api_router.put('/{provider}/{model_name:path}')
|
||||
async def update_verified_model(
|
||||
provider: str,
|
||||
model_name: str,
|
||||
data: VerifiedModelUpdate,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> VerifiedModel:
|
||||
"""Update a verified model by provider and model name."""
|
||||
model = await verified_model_service.update_verified_model(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
is_enabled=data.is_enabled,
|
||||
)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Model {provider}/{model_name} not found',
|
||||
)
|
||||
return model
|
||||
|
||||
|
||||
@api_router.delete('/{provider}/{model_name:path}')
|
||||
async def delete_verified_model(
|
||||
provider: str,
|
||||
model_name: str,
|
||||
user_id: str = Depends(get_admin_user_id),
|
||||
verified_model_service: VerifiedModelService = Depends(
|
||||
verified_model_store_dependency
|
||||
),
|
||||
) -> bool:
|
||||
"""Delete a verified model by provider and model name."""
|
||||
try:
|
||||
await verified_model_service.delete_verified_model(
|
||||
model_name=model_name, provider=provider
|
||||
)
|
||||
return True
|
||||
except ValueError as ex:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=str(ex),
|
||||
)
|
||||
|
||||
|
||||
async def get_saas_llm_models_dependency(request: Request) -> list[str]:
|
||||
"""SaaS implementation for the LLM models endpoint."""
|
||||
async with get_db_session(request.state, request) as db_session:
|
||||
# Prevent circular import
|
||||
from openhands.server.shared import config
|
||||
|
||||
verified_model_service = VerifiedModelService(db_session)
|
||||
page = await verified_model_service.search_verified_models(enabled_only=True)
|
||||
if page.next_page_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail='Too many models defined in database',
|
||||
)
|
||||
verified_models = [f'{m.provider}/{m.model_name}' for m in page.items]
|
||||
return get_supported_llm_models(config, verified_models)
|
||||
|
||||
|
||||
# Override the default implementation with SaaS implementation
|
||||
# This must be called after the app is created in saas_server.py
|
||||
def override_llm_models_dependency(app):
|
||||
"""Override the default LLM models implementation with SaaS version."""
|
||||
app.dependency_overrides[public.get_llm_models_dependency] = (
|
||||
get_saas_llm_models_dependency
|
||||
)
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Store for managing verified LLM models in the database."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Identity,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
and_,
|
||||
func,
|
||||
select,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.base import Base
|
||||
|
||||
from enterprise.server.verified_models.verified_model_models import (
|
||||
VerifiedModel,
|
||||
VerifiedModelPage,
|
||||
)
|
||||
from openhands.app_server.config import depends_db_session
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class StoredVerifiedModel(Base): # type: ignore
|
||||
"""A verified LLM model available in the model selector.
|
||||
|
||||
The composite unique constraint on (model_name, provider) allows the same
|
||||
model name to exist under different providers (e.g. 'claude-sonnet' under
|
||||
both 'openhands' and 'anthropic').
|
||||
"""
|
||||
|
||||
__tablename__ = 'verified_models'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('model_name', 'provider', name='uq_verified_model_provider'),
|
||||
)
|
||||
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
model_name = Column(String(255), nullable=False)
|
||||
provider = Column(String(100), nullable=False, index=True)
|
||||
is_enabled = Column(
|
||||
Boolean, nullable=False, default=True, server_default=text('true')
|
||||
)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
def verified_model(result: StoredVerifiedModel) -> VerifiedModel:
|
||||
return VerifiedModel(
|
||||
id=result.id,
|
||||
model_name=result.model_name,
|
||||
provider=result.provider,
|
||||
is_enabled=result.is_enabled,
|
||||
created_at=result.created_at,
|
||||
updated_at=result.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifiedModelService:
|
||||
"""Store for CRUD operations on verified models.
|
||||
|
||||
Follows the async pattern with db_session as an attribute.
|
||||
"""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def search_verified_models(
|
||||
self,
|
||||
provider: str | None = None,
|
||||
enabled_only: bool = True,
|
||||
page_id: str | None = None,
|
||||
limit: int = 100,
|
||||
) -> VerifiedModelPage:
|
||||
"""Search for verified models with optional filtering and pagination.
|
||||
|
||||
Args:
|
||||
provider: Optional provider name to filter by (e.g., 'openhands', 'anthropic')
|
||||
enabled_only: If True, only return enabled models (default: True)
|
||||
page_id: Page id for pagination
|
||||
limit: Maximum number of records to return
|
||||
|
||||
Returns:
|
||||
SearchModelsResult containing items list and has_more flag
|
||||
"""
|
||||
query = select(StoredVerifiedModel)
|
||||
|
||||
# Build filters
|
||||
filters = []
|
||||
if provider:
|
||||
filters.append(StoredVerifiedModel.provider == provider)
|
||||
if enabled_only:
|
||||
filters.append(StoredVerifiedModel.is_enabled.is_(True))
|
||||
|
||||
if filters:
|
||||
query = query.where(and_(*filters))
|
||||
|
||||
# Order by provider, then model_name
|
||||
query = query.order_by(
|
||||
StoredVerifiedModel.provider, StoredVerifiedModel.model_name
|
||||
)
|
||||
|
||||
# Fetch limit + 1 to check if there are more results
|
||||
offset = int(page_id or '0')
|
||||
query = query.offset(offset).limit(limit + 1)
|
||||
|
||||
result = await self.db_session.execute(query)
|
||||
results = list(result.scalars().all())
|
||||
has_more = len(results) > limit
|
||||
next_page_id = None
|
||||
|
||||
# Return only the requested number of results
|
||||
if has_more:
|
||||
next_page_id = str(offset + limit)
|
||||
results.pop()
|
||||
|
||||
items = [verified_model(result) for result in results]
|
||||
return VerifiedModelPage(items=items, next_page_id=next_page_id)
|
||||
|
||||
async def get_model(self, model_name: str, provider: str) -> VerifiedModel | None:
|
||||
"""Get a model by its composite key (model_name, provider).
|
||||
|
||||
Args:
|
||||
model_name: The model identifier
|
||||
provider: The provider name
|
||||
"""
|
||||
query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
async def create_verified_model(
|
||||
self,
|
||||
model_name: str,
|
||||
provider: str,
|
||||
is_enabled: bool = True,
|
||||
) -> VerifiedModel:
|
||||
"""Create a new verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model identifier
|
||||
provider: The provider name
|
||||
is_enabled: Whether the model is enabled (default True)
|
||||
|
||||
Raises:
|
||||
ValueError: If a model with the same (model_name, provider) already exists
|
||||
"""
|
||||
existing_query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(existing_query)
|
||||
existing = result.scalars().first()
|
||||
if existing:
|
||||
raise ValueError(f'Model {provider}/{model_name} already exists')
|
||||
|
||||
model = StoredVerifiedModel(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
is_enabled=is_enabled,
|
||||
)
|
||||
self.db_session.add(model)
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(model)
|
||||
logger.info(f'Created verified model: {provider}/{model_name}')
|
||||
return verified_model(model)
|
||||
|
||||
async def update_verified_model(
|
||||
self,
|
||||
model_name: str,
|
||||
provider: str,
|
||||
is_enabled: bool | None = None,
|
||||
) -> VerifiedModel | None:
|
||||
"""Update an existing verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model name to update
|
||||
provider: The provider name
|
||||
is_enabled: New enabled state (optional)
|
||||
|
||||
Returns:
|
||||
The updated model if found, None otherwise
|
||||
"""
|
||||
query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
model = result.scalars().first()
|
||||
if not model:
|
||||
return None
|
||||
|
||||
if is_enabled is not None:
|
||||
model.is_enabled = is_enabled
|
||||
|
||||
await self.db_session.commit()
|
||||
await self.db_session.refresh(model)
|
||||
logger.info(f'Updated verified model: {provider}/{model_name}')
|
||||
return verified_model(model)
|
||||
|
||||
async def delete_verified_model(self, model_name: str, provider: str):
|
||||
"""Delete a verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model name to delete
|
||||
provider: The provider name
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
query = select(StoredVerifiedModel).where(
|
||||
and_(
|
||||
StoredVerifiedModel.model_name == model_name,
|
||||
StoredVerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
model = result.scalars().first()
|
||||
if not model:
|
||||
raise ValueError('Unknown model')
|
||||
|
||||
await self.db_session.delete(model)
|
||||
await self.db_session.commit()
|
||||
logger.info(f'Deleted verified model: {provider}/{model_name}')
|
||||
|
||||
|
||||
def verified_model_store_dependency(db_session: AsyncSession = depends_db_session()):
|
||||
return VerifiedModelService(db_session)
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Store class for managing organization app settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from server.constants import (
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.routes.org_models import OrgAppSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgAppSettingsStore:
|
||||
"""Store for organization app settings with injected db_session."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
|
||||
"""Get the current organization for a user.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
Org: The organization object, or None if not found
|
||||
"""
|
||||
# Get user with their current_org_id
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
|
||||
org_id = user.current_org_id
|
||||
if not org_id:
|
||||
return None
|
||||
|
||||
# Get the organization
|
||||
result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
return await self._validate_org_version(org)
|
||||
|
||||
async def _validate_org_version(self, org: Org) -> Org:
|
||||
"""Check if we need to update org version.
|
||||
|
||||
Args:
|
||||
org: The organization to validate
|
||||
|
||||
Returns:
|
||||
Org: The validated (and potentially updated) organization
|
||||
"""
|
||||
if org.org_version < ORG_SETTINGS_VERSION:
|
||||
org.org_version = ORG_SETTINGS_VERSION
|
||||
org.default_llm_model = get_default_litellm_model()
|
||||
org.llm_base_url = LITE_LLM_API_URL
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
|
||||
return org
|
||||
|
||||
async def update_org_app_settings(
|
||||
self, org_id: UUID, update_data: OrgAppSettingsUpdate
|
||||
) -> Org | None:
|
||||
"""Update organization app settings.
|
||||
|
||||
Only updates fields that are explicitly provided in update_data.
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
org_id: The organization's ID
|
||||
update_data: Pydantic model with fields to update
|
||||
|
||||
Returns:
|
||||
Org: The updated organization object, or None if not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == org_id).with_for_update()
|
||||
)
|
||||
org = result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Update only explicitly provided fields
|
||||
for field, value in update_data.model_dump(exclude_unset=True).items():
|
||||
setattr(org, field, value)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
return org
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Store class for managing organization LLM settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from storage.org import Org
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrgLLMSettingsStore:
|
||||
"""Store for org LLM settings with injected db_session."""
|
||||
|
||||
db_session: AsyncSession
|
||||
|
||||
async def get_current_org_by_user_id(self, user_id: str) -> Org | None:
|
||||
"""Get the user's current organization.
|
||||
|
||||
Args:
|
||||
user_id: The user's ID (Keycloak user ID)
|
||||
|
||||
Returns:
|
||||
Org: The user's current organization, or None if not found
|
||||
"""
|
||||
# First get the user to find their current_org_id
|
||||
result = await self.db_session.execute(
|
||||
select(User).filter(User.id == uuid.UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user or not user.current_org_id:
|
||||
return None
|
||||
|
||||
# Then get the org
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == user.current_org_id)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def update_org_llm_settings(
|
||||
self, org_id: UUID, update_data: OrgLLMSettingsUpdate
|
||||
) -> Org | None:
|
||||
"""Update organization LLM settings.
|
||||
|
||||
Also propagates relevant settings to all org members.
|
||||
Uses flush() - commit happens at request end via DbSessionInjector.
|
||||
|
||||
Args:
|
||||
org_id: The organization's ID
|
||||
update_data: Pydantic model with fields to update
|
||||
|
||||
Returns:
|
||||
Org: The updated organization, or None if org not found
|
||||
"""
|
||||
result = await self.db_session.execute(
|
||||
select(Org).filter(Org.id == org_id).with_for_update()
|
||||
)
|
||||
org = result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Apply updates to org (excludes llm_api_key which is member-only)
|
||||
update_data.apply_to_org(org)
|
||||
|
||||
# Propagate relevant settings to all org members
|
||||
member_updates = update_data.get_member_updates()
|
||||
if member_updates:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
self.db_session, org_id, member_updates
|
||||
)
|
||||
|
||||
# flush instead of commit - DbSessionInjector auto-commits at request end
|
||||
await self.db_session.flush()
|
||||
await self.db_session.refresh(org)
|
||||
return org
|
||||
@@ -5,9 +5,12 @@ Store class for managing organization-member relationships.
|
||||
from typing import Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.encrypt_utils import encrypt_value
|
||||
from storage.org_member import OrgMember
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
@@ -254,3 +257,28 @@ class OrgMemberStore:
|
||||
members = members[:limit]
|
||||
|
||||
return members, has_more
|
||||
|
||||
@staticmethod
|
||||
async def update_all_members_llm_settings_async(
|
||||
session: AsyncSession,
|
||||
org_id: UUID,
|
||||
member_settings: OrgMemberLLMSettings,
|
||||
) -> None:
|
||||
"""Update LLM settings for all members of an organization.
|
||||
|
||||
Args:
|
||||
session: Database session (passed from caller for transaction)
|
||||
org_id: Organization ID
|
||||
member_settings: Typed LLM settings to apply to all members
|
||||
"""
|
||||
# Build update values from non-None fields
|
||||
values = member_settings.model_dump(exclude_none=True)
|
||||
|
||||
# Handle encrypted llm_api_key field - map to _llm_api_key column with encryption
|
||||
if 'llm_api_key' in values:
|
||||
raw_key = values.pop('llm_api_key')
|
||||
values['_llm_api_key'] = encrypt_value(raw_key)
|
||||
|
||||
if values:
|
||||
stmt = update(OrgMember).where(OrgMember.org_id == org_id).values(**values)
|
||||
await session.execute(stmt)
|
||||
|
||||
@@ -10,10 +10,10 @@ from server.constants import (
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.routes.org_models import OrphanedUserError
|
||||
from sqlalchemy import text
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate, OrphanedUserError
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import joinedload
|
||||
from storage.database import session_maker
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.lite_llm_manager import LiteLlmManager
|
||||
from storage.org import Org
|
||||
from storage.org_member import OrgMember
|
||||
@@ -386,3 +386,47 @@ class OrgStore:
|
||||
extra={'org_id': str(org_id), 'error': str(e)},
|
||||
)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
async def get_org_by_id_async(org_id: UUID) -> Org | None:
|
||||
"""Get organization by ID (async version)."""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
return OrgStore._validate_org_version(org) if org else None
|
||||
|
||||
@staticmethod
|
||||
async def update_org_llm_settings_async(
|
||||
org_id: UUID,
|
||||
llm_settings: OrgLLMSettingsUpdate,
|
||||
) -> Org | None:
|
||||
"""Update organization LLM settings and propagate to members (async version).
|
||||
|
||||
Args:
|
||||
org_id: Organization ID
|
||||
llm_settings: Typed LLM settings update model
|
||||
|
||||
Returns:
|
||||
Updated Org or None if not found
|
||||
"""
|
||||
from storage.org_member_store import OrgMemberStore
|
||||
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
if not org:
|
||||
return None
|
||||
|
||||
# Apply updates to org
|
||||
llm_settings.apply_to_org(org)
|
||||
|
||||
# Propagate relevant settings to all org members
|
||||
member_updates = llm_settings.get_member_updates()
|
||||
if member_updates:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_updates
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(org)
|
||||
return org
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
"""SQLAlchemy model for verified LLM models."""
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Identity,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
text,
|
||||
)
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
class VerifiedModel(Base): # type: ignore
|
||||
"""A verified LLM model available in the model selector.
|
||||
|
||||
The composite unique constraint on (model_name, provider) allows the same
|
||||
model name to exist under different providers (e.g. 'claude-sonnet' under
|
||||
both 'openhands' and 'anthropic').
|
||||
"""
|
||||
|
||||
__tablename__ = 'verified_models'
|
||||
__table_args__ = (
|
||||
UniqueConstraint('model_name', 'provider', name='uq_verified_model_provider'),
|
||||
)
|
||||
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
model_name = Column(String(255), nullable=False)
|
||||
provider = Column(String(100), nullable=False, index=True)
|
||||
is_enabled = Column(
|
||||
Boolean, nullable=False, default=True, server_default=text('true')
|
||||
)
|
||||
created_at = Column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at = Column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,187 +0,0 @@
|
||||
"""Store for managing verified LLM models in the database."""
|
||||
|
||||
from sqlalchemy import and_
|
||||
from storage.database import session_maker
|
||||
from storage.verified_model import VerifiedModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class VerifiedModelStore:
|
||||
"""Store for CRUD operations on verified models.
|
||||
|
||||
Follows the project convention of static methods with session_maker()
|
||||
(see UserStore, OrgMemberStore for reference).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_enabled_models() -> list[VerifiedModel]:
|
||||
"""Get all enabled models.
|
||||
|
||||
Returns:
|
||||
list[VerifiedModel]: All models where is_enabled is True
|
||||
"""
|
||||
with session_maker() as session:
|
||||
return (
|
||||
session.query(VerifiedModel)
|
||||
.filter(VerifiedModel.is_enabled.is_(True))
|
||||
.order_by(VerifiedModel.provider, VerifiedModel.model_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_models_by_provider(provider: str) -> list[VerifiedModel]:
|
||||
"""Get all enabled models for a specific provider.
|
||||
|
||||
Args:
|
||||
provider: The provider name (e.g., 'openhands', 'anthropic')
|
||||
"""
|
||||
with session_maker() as session:
|
||||
return (
|
||||
session.query(VerifiedModel)
|
||||
.filter(
|
||||
and_(
|
||||
VerifiedModel.provider == provider,
|
||||
VerifiedModel.is_enabled.is_(True),
|
||||
)
|
||||
)
|
||||
.order_by(VerifiedModel.model_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_all_models() -> list[VerifiedModel]:
|
||||
"""Get all models (including disabled)."""
|
||||
with session_maker() as session:
|
||||
return (
|
||||
session.query(VerifiedModel)
|
||||
.order_by(VerifiedModel.provider, VerifiedModel.model_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_model(model_name: str, provider: str) -> VerifiedModel | None:
|
||||
"""Get a model by its composite key (model_name, provider).
|
||||
|
||||
Args:
|
||||
model_name: The model identifier
|
||||
provider: The provider name
|
||||
"""
|
||||
with session_maker() as session:
|
||||
return (
|
||||
session.query(VerifiedModel)
|
||||
.filter(
|
||||
and_(
|
||||
VerifiedModel.model_name == model_name,
|
||||
VerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def create_model(
|
||||
model_name: str, provider: str, is_enabled: bool = True
|
||||
) -> VerifiedModel:
|
||||
"""Create a new verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model identifier
|
||||
provider: The provider name
|
||||
is_enabled: Whether the model is enabled (default True)
|
||||
|
||||
Raises:
|
||||
ValueError: If a model with the same (model_name, provider) already exists
|
||||
"""
|
||||
with session_maker() as session:
|
||||
existing = (
|
||||
session.query(VerifiedModel)
|
||||
.filter(
|
||||
and_(
|
||||
VerifiedModel.model_name == model_name,
|
||||
VerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ValueError(f'Model {provider}/{model_name} already exists')
|
||||
|
||||
model = VerifiedModel(
|
||||
model_name=model_name,
|
||||
provider=provider,
|
||||
is_enabled=is_enabled,
|
||||
)
|
||||
session.add(model)
|
||||
session.commit()
|
||||
session.refresh(model)
|
||||
logger.info(f'Created verified model: {provider}/{model_name}')
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def update_model(
|
||||
model_name: str,
|
||||
provider: str,
|
||||
is_enabled: bool | None = None,
|
||||
) -> VerifiedModel | None:
|
||||
"""Update an existing verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model name to update
|
||||
provider: The provider name
|
||||
is_enabled: New enabled state (optional)
|
||||
|
||||
Returns:
|
||||
The updated model if found, None otherwise
|
||||
"""
|
||||
with session_maker() as session:
|
||||
model = (
|
||||
session.query(VerifiedModel)
|
||||
.filter(
|
||||
and_(
|
||||
VerifiedModel.model_name == model_name,
|
||||
VerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not model:
|
||||
return None
|
||||
|
||||
if is_enabled is not None:
|
||||
model.is_enabled = is_enabled
|
||||
|
||||
session.commit()
|
||||
session.refresh(model)
|
||||
logger.info(f'Updated verified model: {provider}/{model_name}')
|
||||
return model
|
||||
|
||||
@staticmethod
|
||||
def delete_model(model_name: str, provider: str) -> bool:
|
||||
"""Delete a verified model.
|
||||
|
||||
Args:
|
||||
model_name: The model name to delete
|
||||
provider: The provider name
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
with session_maker() as session:
|
||||
model = (
|
||||
session.query(VerifiedModel)
|
||||
.filter(
|
||||
and_(
|
||||
VerifiedModel.model_name == model_name,
|
||||
VerifiedModel.provider == provider,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not model:
|
||||
return False
|
||||
|
||||
session.delete(model)
|
||||
session.commit()
|
||||
logger.info(f'Deleted verified model: {provider}/{model_name}')
|
||||
return True
|
||||
@@ -4,6 +4,9 @@ from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from server.constants import ORG_SETTINGS_VERSION
|
||||
from server.verified_models.verified_model_service import (
|
||||
StoredVerifiedModel, # noqa: F401
|
||||
)
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.base import Base
|
||||
@@ -25,7 +28,6 @@ from storage.stored_conversation_metadata_saas import (
|
||||
from storage.stored_offline_token import StoredOfflineToken
|
||||
from storage.stripe_customer import StripeCustomer
|
||||
from storage.user import User
|
||||
from storage.verified_model import VerifiedModel # noqa: F401
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -24,6 +24,8 @@ with patch('storage.database.engine', create=True), patch(
|
||||
LastOwnerError,
|
||||
LiteLLMIntegrationError,
|
||||
MeResponse,
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgAuthorizationError,
|
||||
OrgDatabaseError,
|
||||
OrgMemberNotFoundError,
|
||||
@@ -3424,3 +3426,421 @@ async def test_switch_org_database_error(mock_app_with_get_user_id):
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'Failed to switch organization' in response.json()['detail']
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for App Settings Endpoints
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_member_role():
|
||||
"""Create a mock member role for authorization tests."""
|
||||
mock_role = MagicMock()
|
||||
mock_role.name = 'member'
|
||||
return mock_role
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_success(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Authenticated user with MANAGE_APPLICATION_SETTINGS permission
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: App settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=10.0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data['enable_proactive_conversation_starters'] is True
|
||||
assert response_data['enable_solvability_analysis'] is False
|
||||
assert response_data['max_budget_per_task'] == 10.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_with_null_values(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization has null app settings values
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: Default values are returned where applicable
|
||||
"""
|
||||
# Arrange
|
||||
# OrgAppSettingsResponse.from_org() handles defaults, so we test the response model
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=True, # Default when None in Org
|
||||
enable_solvability_analysis=None,
|
||||
max_budget_per_task=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
# enable_proactive_conversation_starters defaults to True when None
|
||||
assert response_data['enable_proactive_conversation_starters'] is True
|
||||
assert response_data['enable_solvability_analysis'] is None
|
||||
assert response_data['max_budget_per_task'] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_not_found(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: User has no current organization
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: 404 Not Found error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.get_org_app_settings',
|
||||
AsyncMock(side_effect=OrgNotFoundError('current')),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_user_not_member(mock_app_with_get_user_id):
|
||||
"""
|
||||
GIVEN: User is not a member of any organization
|
||||
WHEN: GET /api/organizations/app is called
|
||||
THEN: 403 Forbidden error is returned
|
||||
"""
|
||||
# Arrange - user has no role (not a member)
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.get('/api/organizations/app')
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'not a member' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_success(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Valid update data and authenticated user
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: Updated app settings are returned with 200 status
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=False,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=25.0,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
) as mock_update,
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={
|
||||
'enable_proactive_conversation_starters': False,
|
||||
'enable_solvability_analysis': True,
|
||||
'max_budget_per_task': 25.0,
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data['enable_proactive_conversation_starters'] is False
|
||||
assert response_data['enable_solvability_analysis'] is True
|
||||
assert response_data['max_budget_per_task'] == 25.0
|
||||
mock_update.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_partial_update(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Partial update data (only some fields)
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: Only specified fields are updated
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=False,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=10.0, # Unchanged
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
) as mock_update,
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - only updating one field
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_update.assert_called_once()
|
||||
# Verify the update data only contains the specified field
|
||||
call_args = mock_update.call_args
|
||||
update_data = call_args[0][0] # First positional argument (update_data)
|
||||
assert isinstance(update_data, OrgAppSettingsUpdate)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_set_null(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Request to set max_budget_per_task to null
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: The field is set to null successfully
|
||||
"""
|
||||
# Arrange
|
||||
mock_response = OrgAppSettingsResponse(
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(return_value=mock_response),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - explicitly setting max_budget_per_task to null
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'max_budget_per_task': None},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
response_data = response.json()
|
||||
assert response_data['max_budget_per_task'] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_invalid_max_budget(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Invalid max_budget_per_task value (zero or negative)
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 422 Validation error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - negative value
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'max_budget_per_task': -5.0},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_zero_max_budget(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: max_budget_per_task is set to zero
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 422 Validation error is returned (must be greater than 0)
|
||||
"""
|
||||
# Arrange
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act - zero value
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'max_budget_per_task': 0},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_not_found(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: User has no current organization
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 404 Not Found error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(side_effect=OrgNotFoundError('current')),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert 'not found' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_database_error(
|
||||
mock_app_with_get_user_id, mock_member_role
|
||||
):
|
||||
"""
|
||||
GIVEN: Database update fails
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 500 Internal Server Error is returned
|
||||
"""
|
||||
# Arrange
|
||||
with (
|
||||
patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=mock_member_role),
|
||||
),
|
||||
patch(
|
||||
'server.routes.orgs.OrgAppSettingsService.update_org_app_settings',
|
||||
AsyncMock(side_effect=Exception('Database connection failed')),
|
||||
),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert 'unexpected error' in response.json()['detail'].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_user_not_member(mock_app_with_get_user_id):
|
||||
"""
|
||||
GIVEN: User is not a member of any organization
|
||||
WHEN: POST /api/organizations/app is called
|
||||
THEN: 403 Forbidden error is returned
|
||||
"""
|
||||
# Arrange - user has no role (not a member)
|
||||
with patch(
|
||||
'server.auth.authorization.get_user_org_role_async',
|
||||
AsyncMock(return_value=None),
|
||||
):
|
||||
client = TestClient(mock_app_with_get_user_id)
|
||||
|
||||
# Act
|
||||
response = client.post(
|
||||
'/api/organizations/app',
|
||||
json={'enable_proactive_conversation_starters': False},
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert 'not a member' in response.json()['detail'].lower()
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Unit tests for OrgAppSettingsService.
|
||||
|
||||
Tests the service layer for organization app settings operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import (
|
||||
OrgAppSettingsResponse,
|
||||
OrgAppSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from server.services.org_app_settings_service import OrgAppSettingsService
|
||||
from storage.org import Org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
"""Create a test user ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_org():
|
||||
"""Create a mock organization with app settings."""
|
||||
org = MagicMock(spec=Org)
|
||||
org.id = uuid.uuid4()
|
||||
org.enable_proactive_conversation_starters = True
|
||||
org.enable_solvability_analysis = False
|
||||
org.max_budget_per_task = 25.0
|
||||
return org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
"""Create a mock OrgAppSettingsStore."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_context(user_id):
|
||||
"""Create a mock UserContext that returns the user_id."""
|
||||
context = MagicMock()
|
||||
context.get_user_id = AsyncMock(return_value=user_id)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user's current organization exists
|
||||
WHEN: get_org_app_settings is called
|
||||
THEN: OrgAppSettingsResponse is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.get_org_app_settings()
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgAppSettingsResponse)
|
||||
assert result.enable_proactive_conversation_starters is True
|
||||
assert result.enable_solvability_analysis is False
|
||||
assert result.max_budget_per_task == 25.0
|
||||
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_app_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: get_org_app_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.get_org_app_settings()
|
||||
|
||||
assert 'current' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user's current organization exists
|
||||
WHEN: update_org_app_settings is called with new values
|
||||
THEN: OrgAppSettingsResponse is returned with updated data
|
||||
"""
|
||||
# Arrange
|
||||
mock_org.enable_proactive_conversation_starters = False
|
||||
mock_org.max_budget_per_task = 50.0
|
||||
|
||||
update_data = OrgAppSettingsUpdate(
|
||||
enable_proactive_conversation_starters=False,
|
||||
max_budget_per_task=50.0,
|
||||
)
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_app_settings = AsyncMock(return_value=mock_org)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgAppSettingsResponse)
|
||||
assert result.enable_proactive_conversation_starters is False
|
||||
assert result.max_budget_per_task == 50.0
|
||||
mock_store.update_org_app_settings.assert_called_once_with(
|
||||
org_id=mock_org.id, update_data=update_data
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_no_changes(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user's current organization exists
|
||||
WHEN: update_org_app_settings is called with no fields
|
||||
THEN: Current settings are returned without calling update
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgAppSettingsUpdate() # No fields set
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_app_settings = AsyncMock()
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_app_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgAppSettingsResponse)
|
||||
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
|
||||
mock_store.update_org_app_settings.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: update_org_app_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgAppSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.update_org_app_settings(update_data)
|
||||
|
||||
assert 'current' in str(exc_info.value)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Unit tests for OrgLLMSettingsService.
|
||||
|
||||
Tests the service layer for organization LLM settings operations.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from server.routes.org_models import (
|
||||
OrgLLMSettingsResponse,
|
||||
OrgLLMSettingsUpdate,
|
||||
OrgNotFoundError,
|
||||
)
|
||||
from server.services.org_llm_settings_service import OrgLLMSettingsService
|
||||
from storage.org import Org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_id():
|
||||
"""Create a test user ID."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def org_id():
|
||||
"""Create a test org ID."""
|
||||
return uuid.uuid4()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_org(org_id):
|
||||
"""Create a mock organization with LLM settings."""
|
||||
org = MagicMock(spec=Org)
|
||||
org.id = org_id
|
||||
org.default_llm_model = 'claude-3'
|
||||
org.default_llm_base_url = 'https://api.anthropic.com'
|
||||
org.search_api_key = None
|
||||
org.agent = 'CodeActAgent'
|
||||
org.confirmation_mode = True
|
||||
org.security_analyzer = None
|
||||
org.enable_default_condenser = True
|
||||
org.condenser_max_size = None
|
||||
org.default_max_iterations = 50
|
||||
return org
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_store():
|
||||
"""Create a mock OrgLLMSettingsStore."""
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_user_context(user_id):
|
||||
"""Create a mock UserContext that returns the user_id."""
|
||||
context = MagicMock()
|
||||
context.get_user_id = AsyncMock(return_value=user_id)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_llm_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with a current organization
|
||||
WHEN: get_org_llm_settings is called
|
||||
THEN: OrgLLMSettingsResponse is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.get_org_llm_settings()
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgLLMSettingsResponse)
|
||||
assert result.default_llm_model == 'claude-3'
|
||||
assert result.agent == 'CodeActAgent'
|
||||
mock_store.get_current_org_by_user_id.assert_called_once_with(user_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_llm_settings_user_not_authenticated(mock_store):
|
||||
"""
|
||||
GIVEN: A user is not authenticated
|
||||
WHEN: get_org_llm_settings is called
|
||||
THEN: ValueError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_user_context = MagicMock()
|
||||
mock_user_context.get_user_id = AsyncMock(return_value=None)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await service.get_org_llm_settings()
|
||||
|
||||
assert 'not authenticated' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_org_llm_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: get_org_llm_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.get_org_llm_settings()
|
||||
|
||||
assert 'No current organization' in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_success(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with a current organization
|
||||
WHEN: update_org_llm_settings is called with new values
|
||||
THEN: OrgLLMSettingsResponse is returned with updated data
|
||||
"""
|
||||
# Arrange
|
||||
updated_org = MagicMock(spec=Org)
|
||||
updated_org.id = mock_org.id
|
||||
updated_org.default_llm_model = 'new-model'
|
||||
updated_org.default_llm_base_url = None
|
||||
updated_org.search_api_key = None
|
||||
updated_org.agent = 'CodeActAgent'
|
||||
updated_org.confirmation_mode = False
|
||||
updated_org.security_analyzer = None
|
||||
updated_org.enable_default_condenser = True
|
||||
updated_org.condenser_max_size = None
|
||||
updated_org.default_max_iterations = 100
|
||||
|
||||
update_data = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
confirmation_mode=False,
|
||||
default_max_iterations=100,
|
||||
)
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_llm_settings = AsyncMock(return_value=updated_org)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_llm_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgLLMSettingsResponse)
|
||||
assert result.default_llm_model == 'new-model'
|
||||
assert result.confirmation_mode is False
|
||||
assert result.default_max_iterations == 100
|
||||
mock_store.update_org_llm_settings.assert_called_once_with(
|
||||
org_id=mock_org.id,
|
||||
update_data=update_data,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_no_changes(
|
||||
user_id, mock_org, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user with a current organization
|
||||
WHEN: update_org_llm_settings is called with no fields
|
||||
THEN: Current settings are returned without calling update
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgLLMSettingsUpdate() # No fields set
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=mock_org)
|
||||
mock_store.update_org_llm_settings = AsyncMock()
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act
|
||||
result = await service.update_org_llm_settings(update_data)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, OrgLLMSettingsResponse)
|
||||
assert result.default_llm_model == 'claude-3'
|
||||
mock_store.update_org_llm_settings.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_org_not_found(
|
||||
user_id, mock_store, mock_user_context
|
||||
):
|
||||
"""
|
||||
GIVEN: A user has no current organization
|
||||
WHEN: update_org_llm_settings is called
|
||||
THEN: OrgNotFoundError is raised
|
||||
"""
|
||||
# Arrange
|
||||
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
|
||||
|
||||
mock_store.get_current_org_by_user_id = AsyncMock(return_value=None)
|
||||
service = OrgLLMSettingsService(store=mock_store, user_context=mock_user_context)
|
||||
|
||||
# Act & Assert
|
||||
with pytest.raises(OrgNotFoundError) as exc_info:
|
||||
await service.update_org_llm_settings(update_data)
|
||||
|
||||
assert 'No current organization' in str(exc_info.value)
|
||||
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Unit tests for OrgAppSettingsStore.
|
||||
|
||||
Tests the async database operations for organization app settings.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from server.routes.org_models import OrgAppSettingsUpdate
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_app_settings_store import OrgAppSettingsStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists with a current organization
|
||||
WHEN: get_current_org_by_user_id is called with the user's ID
|
||||
THEN: The organization is returned with correct data
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(
|
||||
name='test-org',
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=25.0,
|
||||
)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(
|
||||
id=uuid.uuid4(),
|
||||
current_org_id=org.id,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Act
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.name == 'test-org'
|
||||
assert result.enable_proactive_conversation_starters is True
|
||||
assert result.enable_solvability_analysis is False
|
||||
assert result.max_budget_per_task == 25.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_user_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_current_org_by_user_id is called with a non-existent ID
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(non_existent_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists in the database
|
||||
WHEN: update_org_app_settings is called with new values
|
||||
THEN: The organization's settings are updated and returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(
|
||||
name='test-org',
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=10.0,
|
||||
)
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
update_data = OrgAppSettingsUpdate(
|
||||
enable_proactive_conversation_starters=False,
|
||||
enable_solvability_analysis=True,
|
||||
max_budget_per_task=50.0,
|
||||
)
|
||||
|
||||
# Act
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.update_org_app_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.enable_proactive_conversation_starters is False
|
||||
assert result.enable_solvability_analysis is True
|
||||
assert result.max_budget_per_task == 50.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_partial(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists with existing settings
|
||||
WHEN: update_org_app_settings is called with only some fields
|
||||
THEN: Only the provided fields are updated, others remain unchanged
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(
|
||||
name='test-org',
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_solvability_analysis=False,
|
||||
max_budget_per_task=10.0,
|
||||
)
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Only update max_budget_per_task
|
||||
update_data = OrgAppSettingsUpdate(max_budget_per_task=100.0)
|
||||
|
||||
# Act
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.update_org_app_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.max_budget_per_task == 100.0
|
||||
assert result.enable_proactive_conversation_starters is True # Unchanged
|
||||
assert result.enable_solvability_analysis is False # Unchanged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_app_settings_org_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization does not exist in the database
|
||||
WHEN: update_org_app_settings is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = uuid.uuid4()
|
||||
update_data = OrgAppSettingsUpdate(enable_proactive_conversation_starters=False)
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgAppSettingsStore(db_session=session)
|
||||
result = await store.update_org_app_settings(non_existent_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Unit tests for OrgLLMSettingsStore.
|
||||
|
||||
Tests the async database operations for organization LLM settings.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Mock the database module before importing
|
||||
with patch('storage.database.engine', create=True), patch(
|
||||
'storage.database.a_engine', create=True
|
||||
):
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
from storage.base import Base
|
||||
from storage.org import Org
|
||||
from storage.org_llm_settings_store import OrgLLMSettingsStore
|
||||
from storage.user import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user exists with a current_org_id
|
||||
WHEN: get_current_org_by_user_id is called
|
||||
THEN: The user's current organization is returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org', default_llm_model='claude-3')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
user_id = str(user.id)
|
||||
|
||||
# Act
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(user_id)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.name == 'test-org'
|
||||
assert result.default_llm_model == 'claude-3'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_current_org_by_user_id_user_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: A user does not exist in the database
|
||||
WHEN: get_current_org_by_user_id is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_id = str(uuid.uuid4())
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
result = await store.get_current_org_by_user_id(non_existent_id)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_success(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists in the database
|
||||
WHEN: update_org_llm_settings is called with new values
|
||||
THEN: The organization's LLM settings are updated and returned
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org', default_llm_model='old-model')
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
update_data = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
agent='CodeActAgent',
|
||||
confirmation_mode=True,
|
||||
)
|
||||
|
||||
# Act
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
with patch(
|
||||
'storage.org_llm_settings_store.OrgMemberStore.update_all_members_llm_settings_async',
|
||||
AsyncMock(),
|
||||
):
|
||||
result = await store.update_org_llm_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is not None
|
||||
assert result.default_llm_model == 'new-model'
|
||||
assert result.agent == 'CodeActAgent'
|
||||
assert result.confirmation_mode is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_org_not_found(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization does not exist in the database
|
||||
WHEN: update_org_llm_settings is called
|
||||
THEN: None is returned
|
||||
"""
|
||||
# Arrange
|
||||
non_existent_org_id = uuid.uuid4()
|
||||
update_data = OrgLLMSettingsUpdate(default_llm_model='new-model')
|
||||
|
||||
# Act
|
||||
async with async_session_maker() as session:
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
result = await store.update_org_llm_settings(non_existent_org_id, update_data)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_propagates_to_members(async_session_maker):
|
||||
"""
|
||||
GIVEN: An organization exists with update data containing member-relevant settings
|
||||
WHEN: update_org_llm_settings is called
|
||||
THEN: Member settings are propagated via OrgMemberStore
|
||||
"""
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org', default_llm_model='old-model')
|
||||
session.add(org)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
update_data = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
llm_api_key='new-api-key',
|
||||
)
|
||||
|
||||
# Act
|
||||
store = OrgLLMSettingsStore(db_session=session)
|
||||
with patch(
|
||||
'storage.org_llm_settings_store.OrgMemberStore.update_all_members_llm_settings_async',
|
||||
AsyncMock(),
|
||||
) as mock_update_members:
|
||||
await store.update_org_llm_settings(org_id, update_data)
|
||||
|
||||
# Assert
|
||||
mock_update_members.assert_called_once()
|
||||
call_args = mock_update_members.call_args
|
||||
member_settings = call_args[0][2]
|
||||
assert member_settings.llm_model == 'new-model'
|
||||
assert member_settings.llm_api_key == 'new-api-key'
|
||||
@@ -1,123 +0,0 @@
|
||||
"""Unit tests for VerifiedModelStore."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.base import Base
|
||||
from storage.verified_model_store import VerifiedModelStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_session_maker():
|
||||
"""Create an in-memory SQLite database and patch session_maker."""
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine)
|
||||
|
||||
with patch(
|
||||
'storage.verified_model_store.session_maker',
|
||||
side_effect=lambda **kwargs: session_factory(**kwargs),
|
||||
):
|
||||
yield
|
||||
|
||||
Base.metadata.drop_all(engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _seed_models(_mock_session_maker):
|
||||
"""Seed the database with test models."""
|
||||
VerifiedModelStore.create_model(model_name='claude-sonnet', provider='openhands')
|
||||
VerifiedModelStore.create_model(model_name='claude-sonnet', provider='anthropic')
|
||||
VerifiedModelStore.create_model(
|
||||
model_name='gpt-4o', provider='openhands', is_enabled=False
|
||||
)
|
||||
|
||||
|
||||
class TestCreateModel:
|
||||
def test_create_model(self, _mock_session_maker):
|
||||
model = VerifiedModelStore.create_model(
|
||||
model_name='test-model', provider='test-provider'
|
||||
)
|
||||
assert model.model_name == 'test-model'
|
||||
assert model.provider == 'test-provider'
|
||||
assert model.is_enabled is True
|
||||
assert model.id is not None
|
||||
|
||||
def test_create_duplicate_raises(self, _mock_session_maker):
|
||||
VerifiedModelStore.create_model(model_name='test-model', provider='test')
|
||||
with pytest.raises(ValueError, match='test/test-model already exists'):
|
||||
VerifiedModelStore.create_model(model_name='test-model', provider='test')
|
||||
|
||||
def test_same_name_different_provider_allowed(self, _mock_session_maker):
|
||||
VerifiedModelStore.create_model(model_name='claude', provider='openhands')
|
||||
model = VerifiedModelStore.create_model(
|
||||
model_name='claude', provider='anthropic'
|
||||
)
|
||||
assert model.provider == 'anthropic'
|
||||
|
||||
|
||||
class TestGetModel:
|
||||
def test_get_model(self, _seed_models):
|
||||
model = VerifiedModelStore.get_model('claude-sonnet', 'openhands')
|
||||
assert model is not None
|
||||
assert model.provider == 'openhands'
|
||||
|
||||
def test_get_model_not_found(self, _seed_models):
|
||||
assert VerifiedModelStore.get_model('nonexistent', 'openhands') is None
|
||||
|
||||
def test_get_model_wrong_provider(self, _seed_models):
|
||||
assert VerifiedModelStore.get_model('claude-sonnet', 'openai') is None
|
||||
|
||||
|
||||
class TestGetModels:
|
||||
def test_get_all_models(self, _seed_models):
|
||||
models = VerifiedModelStore.get_all_models()
|
||||
assert len(models) == 3
|
||||
|
||||
def test_get_enabled_models(self, _seed_models):
|
||||
models = VerifiedModelStore.get_enabled_models()
|
||||
assert len(models) == 2
|
||||
names = {m.model_name for m in models}
|
||||
assert 'gpt-4o' not in names
|
||||
|
||||
def test_get_models_by_provider(self, _seed_models):
|
||||
models = VerifiedModelStore.get_models_by_provider('openhands')
|
||||
assert len(models) == 1
|
||||
assert models[0].model_name == 'claude-sonnet'
|
||||
|
||||
|
||||
class TestUpdateModel:
|
||||
def test_update_model(self, _seed_models):
|
||||
updated = VerifiedModelStore.update_model(
|
||||
model_name='claude-sonnet', provider='openhands', is_enabled=False
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.is_enabled is False
|
||||
|
||||
def test_update_not_found(self, _seed_models):
|
||||
assert (
|
||||
VerifiedModelStore.update_model(
|
||||
model_name='nonexistent', provider='openhands', is_enabled=False
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
def test_update_no_change(self, _seed_models):
|
||||
updated = VerifiedModelStore.update_model(
|
||||
model_name='claude-sonnet', provider='openhands'
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.is_enabled is True
|
||||
|
||||
|
||||
class TestDeleteModel:
|
||||
def test_delete_model(self, _seed_models):
|
||||
assert VerifiedModelStore.delete_model('claude-sonnet', 'openhands') is True
|
||||
assert VerifiedModelStore.get_model('claude-sonnet', 'openhands') is None
|
||||
# Other provider's version should still exist
|
||||
assert VerifiedModelStore.get_model('claude-sonnet', 'anthropic') is not None
|
||||
|
||||
def test_delete_not_found(self, _seed_models):
|
||||
assert VerifiedModelStore.delete_model('nonexistent', 'openhands') is False
|
||||
@@ -832,3 +832,329 @@ async def test_get_org_members_paginated_email_filter_case_insensitive(
|
||||
# Assert
|
||||
assert len(members) == 1
|
||||
assert members[0].user.email == 'Alice@Example.COM'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_all_members_llm_settings_async_with_llm_api_key(
|
||||
async_session_maker,
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members and llm_api_key in member settings
|
||||
WHEN: update_all_members_llm_settings_async is called with llm_api_key
|
||||
THEN: The llm_api_key is encrypted and stored in _llm_api_key column for all members
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
from storage.encrypt_utils import decrypt_value
|
||||
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='member', rank=2)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
users = [
|
||||
User(id=uuid.uuid4(), current_org_id=org.id, email=f'user{i}@example.com')
|
||||
for i in range(2)
|
||||
]
|
||||
session.add_all(users)
|
||||
await session.flush()
|
||||
|
||||
org_members = [
|
||||
OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='old-key',
|
||||
status='active',
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
session.add_all(org_members)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
new_api_key = 'new-test-api-key-12345'
|
||||
member_settings = OrgMemberLLMSettings(llm_api_key=new_api_key)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_settings
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Assert
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(OrgMember.org_id == org_id)
|
||||
)
|
||||
updated_members = result.scalars().all()
|
||||
|
||||
assert len(updated_members) == 2
|
||||
for member in updated_members:
|
||||
# Verify the encrypted value can be decrypted to the original
|
||||
decrypted_key = decrypt_value(member._llm_api_key)
|
||||
assert decrypted_key == new_api_key
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_all_members_llm_settings_async_with_non_encrypted_fields(
|
||||
async_session_maker,
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members
|
||||
WHEN: update_all_members_llm_settings_async is called with non-encrypted fields
|
||||
THEN: The fields are updated directly without encryption
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='member', rank=2)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id, email='user@example.com')
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='test-key',
|
||||
llm_model='old-model',
|
||||
max_iterations=10,
|
||||
status='active',
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act
|
||||
member_settings = OrgMemberLLMSettings(
|
||||
llm_model='new-model',
|
||||
llm_base_url='https://new-url.com',
|
||||
max_iterations=50,
|
||||
)
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_settings
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Assert
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(OrgMember.org_id == org_id)
|
||||
)
|
||||
updated_member = result.scalars().first()
|
||||
|
||||
assert updated_member.llm_model == 'new-model'
|
||||
assert updated_member.llm_base_url == 'https://new-url.com'
|
||||
assert updated_member.max_iterations == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_all_members_llm_settings_async_with_empty_settings(
|
||||
async_session_maker,
|
||||
):
|
||||
"""
|
||||
GIVEN: Organization with members and empty member settings
|
||||
WHEN: update_all_members_llm_settings_async is called with no fields set
|
||||
THEN: No database update is performed
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
async with async_session_maker() as session:
|
||||
org = Org(name='test-org')
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
|
||||
role = Role(name='member', rank=2)
|
||||
session.add(role)
|
||||
await session.flush()
|
||||
|
||||
user = User(id=uuid.uuid4(), current_org_id=org.id, email='user@example.com')
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
org_member = OrgMember(
|
||||
org_id=org.id,
|
||||
user_id=user.id,
|
||||
role_id=role.id,
|
||||
llm_api_key='original-key',
|
||||
llm_model='original-model',
|
||||
status='active',
|
||||
)
|
||||
session.add(org_member)
|
||||
await session.commit()
|
||||
org_id = org.id
|
||||
|
||||
# Act - Empty settings (all None)
|
||||
member_settings = OrgMemberLLMSettings()
|
||||
|
||||
async with async_session_maker() as session:
|
||||
await OrgMemberStore.update_all_members_llm_settings_async(
|
||||
session, org_id, member_settings
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Assert - Original values should be unchanged
|
||||
async with async_session_maker() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(OrgMember).filter(OrgMember.org_id == org_id)
|
||||
)
|
||||
member = result.scalars().first()
|
||||
|
||||
assert member.llm_model == 'original-model'
|
||||
# Original key should still be there (encrypted)
|
||||
assert member._llm_api_key is not None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# OrgMemberLLMSettings and OrgLLMSettingsUpdate Model Unit Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_org_member_llm_settings_has_updates_with_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgMemberLLMSettings with only llm_api_key set
|
||||
WHEN: has_updates() is called
|
||||
THEN: Returns True
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
settings = OrgMemberLLMSettings(llm_api_key='test-key')
|
||||
|
||||
# Act
|
||||
result = settings.has_updates()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_org_member_llm_settings_has_updates_empty():
|
||||
"""
|
||||
GIVEN: OrgMemberLLMSettings with no fields set
|
||||
WHEN: has_updates() is called
|
||||
THEN: Returns False
|
||||
"""
|
||||
from server.routes.org_models import OrgMemberLLMSettings
|
||||
|
||||
# Arrange
|
||||
settings = OrgMemberLLMSettings()
|
||||
|
||||
# Act
|
||||
result = settings.has_updates()
|
||||
|
||||
# Assert
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_org_llm_settings_update_apply_to_org_skips_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with llm_api_key and other fields set
|
||||
WHEN: apply_to_org() is called
|
||||
THEN: llm_api_key is NOT applied to org, but other fields are
|
||||
"""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(
|
||||
default_llm_model='claude-3',
|
||||
llm_api_key='should-not-be-applied',
|
||||
)
|
||||
mock_org = MagicMock()
|
||||
mock_org.default_llm_model = None
|
||||
|
||||
# Act
|
||||
settings.apply_to_org(mock_org)
|
||||
|
||||
# Assert
|
||||
assert mock_org.default_llm_model == 'claude-3'
|
||||
# llm_api_key should NOT be set on org (it's member-only)
|
||||
assert (
|
||||
not hasattr(mock_org, 'llm_api_key')
|
||||
or mock_org.llm_api_key != 'should-not-be-applied'
|
||||
)
|
||||
|
||||
|
||||
def test_org_llm_settings_update_get_member_updates_includes_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with llm_api_key set
|
||||
WHEN: get_member_updates() is called
|
||||
THEN: Returns OrgMemberLLMSettings with llm_api_key included
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(
|
||||
default_llm_model='claude-3',
|
||||
llm_api_key='new-member-key',
|
||||
)
|
||||
|
||||
# Act
|
||||
member_updates = settings.get_member_updates()
|
||||
|
||||
# Assert
|
||||
assert member_updates is not None
|
||||
assert member_updates.llm_api_key == 'new-member-key'
|
||||
assert member_updates.llm_model == 'claude-3'
|
||||
|
||||
|
||||
def test_org_llm_settings_update_get_member_updates_only_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with only llm_api_key set
|
||||
WHEN: get_member_updates() is called
|
||||
THEN: Returns OrgMemberLLMSettings with llm_api_key (not None)
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(llm_api_key='member-key-only')
|
||||
|
||||
# Act
|
||||
member_updates = settings.get_member_updates()
|
||||
|
||||
# Assert
|
||||
assert member_updates is not None
|
||||
assert member_updates.llm_api_key == 'member-key-only'
|
||||
assert member_updates.llm_model is None
|
||||
|
||||
|
||||
def test_org_llm_settings_update_has_updates_with_llm_api_key():
|
||||
"""
|
||||
GIVEN: OrgLLMSettingsUpdate with only llm_api_key set
|
||||
WHEN: has_updates() is called
|
||||
THEN: Returns True
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
settings = OrgLLMSettingsUpdate(llm_api_key='test-key')
|
||||
|
||||
# Act
|
||||
result = settings.has_updates()
|
||||
|
||||
# Assert
|
||||
assert result is True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -892,3 +893,98 @@ def test_org_deletion_with_invitations_uses_passive_deletes(
|
||||
with session_maker() as session:
|
||||
deleted_org = session.query(Org).filter(Org.id == org_id).first()
|
||||
assert deleted_org is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tests for async LLM settings methods
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_async_with_llm_api_key():
|
||||
"""
|
||||
GIVEN: Organization with members and llm_api_key in update settings
|
||||
WHEN: update_org_llm_settings_async is called
|
||||
THEN: Org fields are updated and llm_api_key is propagated to all members
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
org_id = uuid.uuid4()
|
||||
|
||||
mock_org = Org(
|
||||
id=org_id,
|
||||
name='Test Organization',
|
||||
default_llm_model='old-model',
|
||||
)
|
||||
|
||||
llm_settings = OrgLLMSettingsUpdate(
|
||||
default_llm_model='new-model',
|
||||
llm_api_key='new-member-api-key',
|
||||
)
|
||||
|
||||
# Mock the async session and member store
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = mock_org
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_session.commit = AsyncMock()
|
||||
mock_session.refresh = AsyncMock()
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_a_session_maker():
|
||||
yield mock_session
|
||||
|
||||
with (
|
||||
patch('storage.org_store.a_session_maker', mock_a_session_maker),
|
||||
patch(
|
||||
'storage.org_member_store.OrgMemberStore.update_all_members_llm_settings_async',
|
||||
AsyncMock(),
|
||||
) as mock_member_update,
|
||||
):
|
||||
# Act
|
||||
result = await OrgStore.update_org_llm_settings_async(org_id, llm_settings)
|
||||
|
||||
# Assert - Org is returned
|
||||
assert result is not None
|
||||
assert result.default_llm_model == 'new-model'
|
||||
|
||||
# Assert - Member update was called with correct settings
|
||||
mock_member_update.assert_called_once()
|
||||
call_args = mock_member_update.call_args
|
||||
member_settings = call_args[0][2] # Third positional arg is member_settings
|
||||
assert member_settings.llm_api_key == 'new-member-api-key'
|
||||
assert member_settings.llm_model == 'new-model'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_org_llm_settings_async_org_not_found():
|
||||
"""
|
||||
GIVEN: Non-existent organization ID
|
||||
WHEN: update_org_llm_settings_async is called
|
||||
THEN: Returns None
|
||||
"""
|
||||
from server.routes.org_models import OrgLLMSettingsUpdate
|
||||
|
||||
# Arrange
|
||||
non_existent_org_id = uuid.uuid4()
|
||||
llm_settings = OrgLLMSettingsUpdate(default_llm_model='new-model')
|
||||
|
||||
# Mock the async session to return None for org
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalars.return_value.first.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
|
||||
@asynccontextmanager
|
||||
async def mock_a_session_maker():
|
||||
yield mock_session
|
||||
|
||||
# Act
|
||||
with patch('storage.org_store.a_session_maker', mock_a_session_maker):
|
||||
result = await OrgStore.update_org_llm_settings_async(
|
||||
non_existent_org_id, llm_settings
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Unit tests for VerifiedModelService."""
|
||||
|
||||
import pytest
|
||||
from server.verified_models.verified_model_service import (
|
||||
VerifiedModelService,
|
||||
)
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from storage.base import Base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_engine():
|
||||
"""Create an async SQLite engine for testing."""
|
||||
engine = create_async_engine(
|
||||
'sqlite+aiosqlite:///:memory:',
|
||||
poolclass=StaticPool,
|
||||
connect_args={'check_same_thread': False},
|
||||
echo=False,
|
||||
)
|
||||
|
||||
# Create all tables
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def async_session_maker(async_engine):
|
||||
"""Create an async session maker for testing."""
|
||||
return async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def _seed_models(async_session_maker):
|
||||
"""Seed the database with test models."""
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.create_verified_model(
|
||||
model_name='claude-sonnet', provider='openhands'
|
||||
)
|
||||
await service.create_verified_model(
|
||||
model_name='claude-sonnet', provider='anthropic'
|
||||
)
|
||||
await service.create_verified_model(
|
||||
model_name='gpt-4o', provider='openhands', is_enabled=False
|
||||
)
|
||||
|
||||
|
||||
class TestCreateVerifiedModel:
|
||||
async def test_create_model(self, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
model = await service.create_verified_model(
|
||||
model_name='test-model', provider='test-provider'
|
||||
)
|
||||
assert model.model_name == 'test-model'
|
||||
assert model.provider == 'test-provider'
|
||||
assert model.is_enabled is True
|
||||
assert model.id is not None
|
||||
|
||||
async def test_create_duplicate_raises(self, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.create_verified_model(
|
||||
model_name='test-model', provider='test'
|
||||
)
|
||||
with pytest.raises(ValueError, match='test/test-model already exists'):
|
||||
await service.create_verified_model(
|
||||
model_name='test-model', provider='test'
|
||||
)
|
||||
|
||||
async def test_same_name_different_provider_allowed(self, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.create_verified_model(
|
||||
model_name='claude', provider='openhands'
|
||||
)
|
||||
model = await service.create_verified_model(
|
||||
model_name='claude', provider='anthropic'
|
||||
)
|
||||
assert model.provider == 'anthropic'
|
||||
|
||||
|
||||
class TestGetModel:
|
||||
async def test_get_model(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
model = await service.get_model('claude-sonnet', 'openhands')
|
||||
assert model is not None
|
||||
assert model.provider == 'openhands'
|
||||
|
||||
async def test_get_model_not_found(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert await service.get_model('nonexistent', 'openhands') is None
|
||||
|
||||
async def test_get_model_wrong_provider(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert await service.get_model('claude-sonnet', 'openai') is None
|
||||
|
||||
|
||||
class TestSearchVerifiedModels:
|
||||
async def test_search_models_no_filters(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models()
|
||||
assert len(result.items) == 2 # Only enabled models
|
||||
assert result.next_page_id is None
|
||||
|
||||
async def test_search_models_enabled_only_true(
|
||||
self, _seed_models, async_session_maker
|
||||
):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(enabled_only=True)
|
||||
assert len(result.items) == 2
|
||||
names = {m.model_name for m in result.items}
|
||||
assert 'gpt-4o' not in names # Disabled model not included
|
||||
|
||||
async def test_search_models_enabled_only_false(
|
||||
self, _seed_models, async_session_maker
|
||||
):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(enabled_only=False)
|
||||
assert len(result.items) == 3 # All models including disabled
|
||||
|
||||
async def test_search_models_by_provider(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(provider='openhands')
|
||||
assert len(result.items) == 1
|
||||
assert result.items[0].model_name == 'claude-sonnet'
|
||||
|
||||
async def test_search_models_pagination(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
# Create more models for pagination testing
|
||||
await service.create_verified_model(model_name='model-1', provider='test')
|
||||
await service.create_verified_model(model_name='model-2', provider='test')
|
||||
await service.create_verified_model(model_name='model-3', provider='test')
|
||||
await service.create_verified_model(model_name='model-4', provider='test')
|
||||
|
||||
# Total: 7 models (3 initial + 4 new)
|
||||
# First page
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(
|
||||
enabled_only=False, page_id='0', limit=3
|
||||
)
|
||||
assert len(result.items) == 3
|
||||
assert result.next_page_id == '3' # 4 more items after position 2
|
||||
|
||||
# Second page (page_id 3)
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(
|
||||
enabled_only=False, page_id='3', limit=3
|
||||
)
|
||||
assert len(result.items) == 3
|
||||
# There are 4 items total starting at offset 3 (positions 3,4,5,6), so next_page_id exists
|
||||
assert result.next_page_id == '6'
|
||||
|
||||
# Third page (page_id 6) - last item
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
result = await service.search_verified_models(
|
||||
enabled_only=False, page_id='6', limit=3
|
||||
)
|
||||
assert len(result.items) == 1
|
||||
assert result.next_page_id is None # No more items after position 6
|
||||
|
||||
|
||||
class TestUpdateVerifiedModel:
|
||||
async def test_update_model(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
updated = await service.update_verified_model(
|
||||
model_name='claude-sonnet', provider='openhands', is_enabled=False
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.is_enabled is False
|
||||
|
||||
async def test_update_not_found(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert (
|
||||
await service.update_verified_model(
|
||||
model_name='nonexistent', provider='openhands', is_enabled=False
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
async def test_update_no_change(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
updated = await service.update_verified_model(
|
||||
model_name='claude-sonnet', provider='openhands'
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.is_enabled is True
|
||||
|
||||
|
||||
class TestDeleteVerifiedModel:
|
||||
async def test_delete_model(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
await service.delete_verified_model('claude-sonnet', 'openhands')
|
||||
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
assert await service.get_model('claude-sonnet', 'openhands') is None
|
||||
# Other provider's version should still exist
|
||||
assert await service.get_model('claude-sonnet', 'anthropic') is not None
|
||||
|
||||
async def test_delete_not_found(self, _seed_models, async_session_maker):
|
||||
async with async_session_maker() as session:
|
||||
service = VerifiedModelService(session)
|
||||
with pytest.raises(ValueError):
|
||||
assert await service.delete_verified_model('nonexistent', 'openhands')
|
||||
@@ -1,18 +1,175 @@
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import axios from "axios";
|
||||
import V1GitService from "../../src/api/git-service/v1-git-service.api";
|
||||
|
||||
vi.mock("axios");
|
||||
|
||||
test("getGitChanges throws when response is not an array (dead runtime returns HTML)", async () => {
|
||||
const htmlResponse = "<!DOCTYPE html><html>...</html>";
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: htmlResponse });
|
||||
describe("V1GitService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
await expect(
|
||||
V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace",
|
||||
),
|
||||
).rejects.toThrow("Invalid response from runtime");
|
||||
describe("getGitChanges", () => {
|
||||
test("throws when response is not an array (dead runtime returns HTML)", async () => {
|
||||
const htmlResponse = "<!DOCTYPE html><html>...</html>";
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: htmlResponse });
|
||||
|
||||
await expect(
|
||||
V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace",
|
||||
),
|
||||
).rejects.toThrow("Invalid response from runtime");
|
||||
});
|
||||
|
||||
test("uses query parameters instead of path segments for the path", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace/project",
|
||||
);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
const [url, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// URL should NOT contain the path - it should end with /api/git/changes
|
||||
expect(url).toContain("/api/git/changes");
|
||||
expect(url).not.toContain("/workspace/project");
|
||||
expect(url).not.toContain(encodeURIComponent("/workspace/project"));
|
||||
|
||||
// Path should be passed as a query parameter
|
||||
expect(config).toHaveProperty("params");
|
||||
expect(config?.params).toEqual({ path: "/workspace/project" });
|
||||
});
|
||||
|
||||
test("preserves slashes in path when using query parameters", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: [] });
|
||||
|
||||
const pathWithSlashes = "/workspace/project/src/components";
|
||||
await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
pathWithSlashes,
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// Path should be preserved exactly as provided (slashes intact)
|
||||
expect(config?.params).toEqual({ path: pathWithSlashes });
|
||||
});
|
||||
|
||||
test("includes session API key in headers when provided", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: [] });
|
||||
|
||||
await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"my-session-key",
|
||||
"/workspace",
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
expect(config?.headers).toEqual({ "X-Session-API-Key": "my-session-key" });
|
||||
});
|
||||
|
||||
test("maps V1 git statuses to V0 format", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: [
|
||||
{ status: "ADDED", path: "new-file.ts" },
|
||||
{ status: "DELETED", path: "removed-file.ts" },
|
||||
{ status: "UPDATED", path: "changed-file.ts" },
|
||||
{ status: "MOVED", path: "renamed-file.ts" },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await V1GitService.getGitChanges(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace",
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ status: "A", path: "new-file.ts" },
|
||||
{ status: "D", path: "removed-file.ts" },
|
||||
{ status: "M", path: "changed-file.ts" },
|
||||
{ status: "R", path: "renamed-file.ts" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGitChangeDiff", () => {
|
||||
test("uses query parameters instead of path segments for the path", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: { diff: "--- a/file.ts\n+++ b/file.ts\n..." },
|
||||
});
|
||||
|
||||
await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace/project/file.ts",
|
||||
);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledTimes(1);
|
||||
const [url, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// URL should NOT contain the path - it should end with /api/git/diff
|
||||
expect(url).toContain("/api/git/diff");
|
||||
expect(url).not.toContain("/workspace/project/file.ts");
|
||||
expect(url).not.toContain(encodeURIComponent("/workspace/project/file.ts"));
|
||||
|
||||
// Path should be passed as a query parameter
|
||||
expect(config).toHaveProperty("params");
|
||||
expect(config?.params).toEqual({ path: "/workspace/project/file.ts" });
|
||||
});
|
||||
|
||||
test("preserves slashes in file path when using query parameters", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: { diff: "diff content" },
|
||||
});
|
||||
|
||||
const filePath = "/workspace/project/src/components/Button.tsx";
|
||||
await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
filePath,
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
|
||||
// Path should be preserved exactly as provided (slashes intact)
|
||||
expect(config?.params).toEqual({ path: filePath });
|
||||
});
|
||||
|
||||
test("includes session API key in headers when provided", async () => {
|
||||
vi.mocked(axios.get).mockResolvedValue({
|
||||
data: { diff: "diff content" },
|
||||
});
|
||||
|
||||
await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"my-session-key",
|
||||
"/workspace/file.ts",
|
||||
);
|
||||
|
||||
const [, config] = vi.mocked(axios.get).mock.calls[0];
|
||||
expect(config?.headers).toEqual({ "X-Session-API-Key": "my-session-key" });
|
||||
});
|
||||
|
||||
test("returns the diff data from the response", async () => {
|
||||
const expectedDiff = {
|
||||
diff: "--- a/file.ts\n+++ b/file.ts\n@@ -1,3 +1,4 @@\n+new line",
|
||||
};
|
||||
vi.mocked(axios.get).mockResolvedValue({ data: expectedDiff });
|
||||
|
||||
const result = await V1GitService.getGitChangeDiff(
|
||||
"http://localhost:3000/api/conversations/123",
|
||||
"test-api-key",
|
||||
"/workspace/file.ts",
|
||||
);
|
||||
|
||||
expect(result).toEqual(expectedDiff);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -235,6 +235,38 @@ describe("LoginContent", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display Bitbucket signup disabled message when Bitbucket is configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github", "bitbucket"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("AUTH$BITBUCKET_SIGNUP_DISABLED"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display Bitbucket signup disabled message when Bitbucket is not configured", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<LoginContent
|
||||
githubAuthUrl="https://github.com/oauth/authorize"
|
||||
appMode="saas"
|
||||
providersConfigured={["github"]}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText("AUTH$BITBUCKET_SIGNUP_DISABLED"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call buildOAuthStateData when clicking auth button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockBuildOAuthStateData = vi.fn((baseState) => ({
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../../../test-utils";
|
||||
import OnboardingForm from "#/routes/onboarding-form";
|
||||
|
||||
const mockMutate = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
vi.mock("react-router", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("react-router")>();
|
||||
return {
|
||||
...original,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
|
||||
useSubmitOnboarding: () => ({
|
||||
mutate: mockMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderOnboardingForm = () => {
|
||||
return renderWithProviders(
|
||||
<MemoryRouter>
|
||||
<OnboardingForm />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("OnboardingForm", () => {
|
||||
beforeEach(() => {
|
||||
mockMutate.mockClear();
|
||||
mockNavigate.mockClear();
|
||||
});
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
expect(screen.getByTestId("onboarding-form")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the first step initially", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
expect(screen.getByTestId("step-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("step-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display step progress indicator with 3 bars", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressBars = stepHeader.querySelectorAll(".rounded-full");
|
||||
expect(progressBars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should have the Next button disabled when no option is selected", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should enable the Next button when an option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should advance to the next step when Next is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// On step 1, first progress bar should be filled (bg-white)
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
let progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 2, first two progress bars should be filled
|
||||
progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should disable Next button again on new step until option is selected", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const nextButton = screen.getByRole("button", { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should call submitOnboarding with selections when finishing the last step", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Step 1 - select role
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 2 - select org size
|
||||
await user.click(screen.getByTestId("step-option-org_2_10"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Step 3 - select use case
|
||||
await user.click(screen.getByTestId("step-option-new_features"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledTimes(1);
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "software_engineer",
|
||||
step2: "org_2_10",
|
||||
step3: "new_features",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should render 6 options on step 1", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const options = screen
|
||||
.getAllByRole("button")
|
||||
.filter((btn) =>
|
||||
btn.getAttribute("data-testid")?.startsWith("step-option-"),
|
||||
);
|
||||
expect(options).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("should preserve selections when navigating through steps", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Select role on step 1
|
||||
await user.click(screen.getByTestId("step-option-cto_founder"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select org size on step 2
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Select use case on step 3
|
||||
await user.click(screen.getByTestId("step-option-fixing_bugs"));
|
||||
await user.click(screen.getByRole("button", { name: /finish/i }));
|
||||
|
||||
// Verify all selections were preserved
|
||||
expect(mockMutate).toHaveBeenCalledWith({
|
||||
selections: {
|
||||
step1: "cto_founder",
|
||||
step2: "solo",
|
||||
step3: "fixing_bugs",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should show all progress bars filled on the last step", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 3
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
await user.click(screen.getByTestId("step-option-solo"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// On step 3, all three progress bars should be filled
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should not render the Back button on the first step", () => {
|
||||
renderOnboardingForm();
|
||||
|
||||
const backButton = screen.queryByRole("button", { name: /back/i });
|
||||
expect(backButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the Back button on step 2", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
const backButton = screen.getByRole("button", { name: /back/i });
|
||||
expect(backButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should go back to the previous step when Back is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderOnboardingForm();
|
||||
|
||||
// Navigate to step 2
|
||||
await user.click(screen.getByTestId("step-option-software_engineer"));
|
||||
await user.click(screen.getByRole("button", { name: /next/i }));
|
||||
|
||||
// Verify we're on step 2 (2 progress bars filled)
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
let progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(2);
|
||||
|
||||
// Click Back
|
||||
await user.click(screen.getByRole("button", { name: /back/i }));
|
||||
|
||||
// Verify we're back on step 1 (1 progress bar filled)
|
||||
progressBars = stepHeader.querySelectorAll(".bg-white");
|
||||
expect(progressBars).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StepContent } from "#/components/features/onboarding/step-content";
|
||||
|
||||
describe("StepContent", () => {
|
||||
const mockOptions = [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
{ id: "option3", label: "Option 3" },
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
options: mockOptions,
|
||||
selectedOptionId: null,
|
||||
onSelectOption: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
render(<StepContent {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all options", () => {
|
||||
render(<StepContent {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Option 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 2")).toBeInTheDocument();
|
||||
expect(screen.getByText("Option 3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onSelectOption with correct id when option is clicked", async () => {
|
||||
const onSelectOptionMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-option2"));
|
||||
|
||||
expect(onSelectOptionMock).toHaveBeenCalledWith("option2");
|
||||
});
|
||||
|
||||
it("should mark the selected option as selected", () => {
|
||||
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
|
||||
|
||||
const selectedOption = screen.getByTestId("step-option-option1");
|
||||
const unselectedOption = screen.getByTestId("step-option-option2");
|
||||
|
||||
expect(selectedOption).toHaveClass("border-white");
|
||||
expect(unselectedOption).toHaveClass("border-[#3a3a3a]");
|
||||
});
|
||||
|
||||
it("should render no options when options array is empty", () => {
|
||||
render(<StepContent {...defaultProps} options={[]} />);
|
||||
|
||||
expect(screen.getByTestId("step-content")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correct number of options", () => {
|
||||
render(<StepContent {...defaultProps} />);
|
||||
|
||||
const options = screen.getAllByRole("button");
|
||||
expect(options).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should allow selecting different options", async () => {
|
||||
const onSelectOptionMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<StepContent {...defaultProps} onSelectOption={onSelectOptionMock} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-option1"));
|
||||
expect(onSelectOptionMock).toHaveBeenCalledWith("option1");
|
||||
|
||||
await user.click(screen.getByTestId("step-option-option3"));
|
||||
expect(onSelectOptionMock).toHaveBeenCalledWith("option3");
|
||||
|
||||
expect(onSelectOptionMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import StepHeader from "#/components/features/onboarding/step-header";
|
||||
|
||||
describe("StepHeader", () => {
|
||||
const defaultProps = {
|
||||
title: "Test Title",
|
||||
currentStep: 1,
|
||||
totalSteps: 3,
|
||||
};
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
render(<StepHeader {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-header")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the title", () => {
|
||||
render(<StepHeader {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render correct number of progress dots based on totalSteps", () => {
|
||||
render(<StepHeader {...defaultProps} totalSteps={5} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressDots = stepHeader.querySelectorAll(".rounded-full");
|
||||
expect(progressDots).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("should fill progress dots up to currentStep", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={2} totalSteps={4} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
|
||||
|
||||
expect(filledDots).toHaveLength(2);
|
||||
expect(unfilledDots).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should show all dots filled when on last step", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={3} totalSteps={3} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
|
||||
|
||||
expect(filledDots).toHaveLength(3);
|
||||
expect(unfilledDots).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should show no dots filled when currentStep is 0", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={0} totalSteps={3} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
const unfilledDots = stepHeader.querySelectorAll(".bg-neutral-600");
|
||||
|
||||
expect(filledDots).toHaveLength(0);
|
||||
expect(unfilledDots).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should handle single step progress", () => {
|
||||
render(<StepHeader {...defaultProps} currentStep={1} totalSteps={1} />);
|
||||
|
||||
const stepHeader = screen.getByTestId("step-header");
|
||||
const progressDots = stepHeader.querySelectorAll(".rounded-full");
|
||||
const filledDots = stepHeader.querySelectorAll(".bg-white");
|
||||
|
||||
expect(progressDots).toHaveLength(1);
|
||||
expect(filledDots).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StepOption } from "#/components/features/onboarding/step-option";
|
||||
|
||||
describe("StepOption", () => {
|
||||
const defaultProps = {
|
||||
id: "test-option",
|
||||
label: "Test Label",
|
||||
selected: false,
|
||||
onClick: vi.fn(),
|
||||
};
|
||||
|
||||
it("should render with the correct test id", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("step-option-test-option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the label", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepOption {...defaultProps} onClick={onClickMock} />);
|
||||
|
||||
await user.click(screen.getByTestId("step-option-test-option"));
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClick when Enter key is pressed", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepOption {...defaultProps} onClick={onClickMock} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
option.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClick when Space key is pressed", async () => {
|
||||
const onClickMock = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<StepOption {...defaultProps} onClick={onClickMock} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
option.focus();
|
||||
await user.keyboard(" ");
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should have role='button' for accessibility", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be focusable with tabIndex=0", () => {
|
||||
render(<StepOption {...defaultProps} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
expect(option).toHaveAttribute("tabIndex", "0");
|
||||
});
|
||||
|
||||
it("should have selected styling when selected is true", () => {
|
||||
render(<StepOption {...defaultProps} selected />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
expect(option).toHaveClass("border-white");
|
||||
});
|
||||
|
||||
it("should have unselected styling when selected is false", () => {
|
||||
render(<StepOption {...defaultProps} selected={false} />);
|
||||
|
||||
const option = screen.getByTestId("step-option-test-option");
|
||||
expect(option).toHaveClass("border-[#3a3a3a]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Unmock the hook so we can test the real implementation
|
||||
vi.unmock("#/hooks/use-is-on-intermediate-page");
|
||||
|
||||
const useLocationMock = vi.fn();
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useLocation: useLocationMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Import after mock setup
|
||||
const { useIsOnIntermediatePage } = await import(
|
||||
"#/hooks/use-is-on-intermediate-page"
|
||||
);
|
||||
|
||||
describe("useIsOnIntermediatePage", () => {
|
||||
describe("returns true for intermediate pages", () => {
|
||||
it("should return true when on /accept-tos page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/accept-tos" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when on /onboarding page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/onboarding" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("returns false for non-intermediate pages", () => {
|
||||
it("should return false when on root page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when on /settings page", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/settings" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles edge cases", () => {
|
||||
it("should return false for paths containing intermediate page names", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/accept-tos-extra" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for paths with intermediate page names as subpaths", () => {
|
||||
useLocationMock.mockReturnValue({ pathname: "/settings/accept-tos" });
|
||||
const { result } = renderHook(() => useIsOnIntermediatePage());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,4 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import {
|
||||
CancelSubscriptionResponse,
|
||||
SubscriptionAccess,
|
||||
} from "./billing.types";
|
||||
|
||||
/**
|
||||
* Billing Service API - Handles all billing-related API endpoints
|
||||
@@ -44,41 +40,6 @@ class BillingService {
|
||||
);
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's subscription access information
|
||||
* @returns The user's subscription access details or null if not available
|
||||
*/
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subscription checkout session for subscribing to a plan
|
||||
* @returns The redirect URL for the subscription checkout session
|
||||
*/
|
||||
static async createSubscriptionCheckoutSession(): Promise<{
|
||||
redirect_url?: string;
|
||||
}> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/subscription-checkout-session",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the user's subscription
|
||||
* @returns The response indicating the result of the cancellation request
|
||||
*/
|
||||
static async cancelSubscription(): Promise<CancelSubscriptionResponse> {
|
||||
const { data } = await openHands.post<CancelSubscriptionResponse>(
|
||||
"/api/billing/cancel-subscription",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default BillingService;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export type SubscriptionAccess = {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
cancelled_at?: string | null;
|
||||
stripe_subscription_id?: string | null;
|
||||
};
|
||||
|
||||
export interface CancelSubscriptionResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -30,7 +30,7 @@ class V1GitService {
|
||||
|
||||
/**
|
||||
* Get git changes for a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/changes/{path}
|
||||
* Uses the agent server endpoint: GET /api/git/changes?path={path}
|
||||
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
@@ -43,15 +43,14 @@ class V1GitService {
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChange[]> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/changes/${encodedPath}`,
|
||||
);
|
||||
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/changes`);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
|
||||
const { data } = await axios.get<V1GitChange[]>(url, { headers });
|
||||
const { data } = await axios.get<V1GitChange[]>(url, {
|
||||
headers,
|
||||
params: { path },
|
||||
});
|
||||
|
||||
// Validate response is an array (could be HTML error page if runtime is dead)
|
||||
if (!Array.isArray(data)) {
|
||||
@@ -69,7 +68,7 @@ class V1GitService {
|
||||
|
||||
/**
|
||||
* Get git change diff for a specific file in a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/diff/{path}
|
||||
* Uses the agent server endpoint: GET /api/git/diff?path={path}
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
@@ -81,14 +80,13 @@ class V1GitService {
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/diff/${encodedPath}`,
|
||||
);
|
||||
const url = this.buildRuntimeUrl(conversationUrl, `/api/git/diff`);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.get<GitChangeDiff>(url, { headers });
|
||||
const { data } = await axios.get<GitChangeDiff>(url, {
|
||||
headers,
|
||||
params: { path },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Bitbucket</title><path fill="currentColor" d="M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z"/></svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 285 B After Width: | Height: | Size: 305 B |
@@ -136,7 +136,11 @@ export function LoginContent({
|
||||
const buttonLabelClasses = "text-sm font-medium leading-5 px-1";
|
||||
|
||||
const shouldShownHelperText =
|
||||
emailVerified || hasDuplicatedEmail || recaptchaBlocked || hasInvitation;
|
||||
emailVerified ||
|
||||
hasDuplicatedEmail ||
|
||||
recaptchaBlocked ||
|
||||
hasInvitation ||
|
||||
showBitbucket;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -173,6 +177,11 @@ export function LoginContent({
|
||||
{t(I18nKey.AUTH$INVITATION_PENDING)}
|
||||
</p>
|
||||
)}
|
||||
{showBitbucket && (
|
||||
<p className="text-sm text-white text-center max-w-125">
|
||||
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { StepOption } from "./step-option";
|
||||
|
||||
export interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface StepContentProps {
|
||||
options: Option[];
|
||||
selectedOptionId: string | null;
|
||||
onSelectOption: (optionId: string) => void;
|
||||
}
|
||||
|
||||
export function StepContent({
|
||||
options,
|
||||
selectedOptionId,
|
||||
onSelectOption,
|
||||
}: StepContentProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="step-content"
|
||||
className="flex flex-col mt-8 mb-8 gap-[12px] w-full"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<StepOption
|
||||
key={option.id}
|
||||
id={option.id}
|
||||
label={option.label}
|
||||
selected={selectedOptionId === option.id}
|
||||
onClick={() => onSelectOption(option.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StepHeaderProps {
|
||||
title: string;
|
||||
currentStep: number;
|
||||
totalSteps: number;
|
||||
}
|
||||
|
||||
function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) {
|
||||
return (
|
||||
<div data-testid="step-header" className="flex flex-col items-center gap-2">
|
||||
<div className="flex justify-center gap-2 mb-2">
|
||||
{Array.from({ length: totalSteps }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-[6px] h-[4px] rounded-full transition-colors",
|
||||
index < currentStep ? "bg-white" : "bg-neutral-600",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Typography.Text className="text-2xl font-semibold text-content text-center">
|
||||
{title}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StepHeader;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface StepOptionProps {
|
||||
id: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function StepOption({ id, label, selected, onClick }: StepOptionProps) {
|
||||
return (
|
||||
<button
|
||||
data-testid={`step-option-${id}`}
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"min-h-10 w-full rounded-md border text-left px-4 py-2.5 transition-colors text-white cursor-pointer",
|
||||
selected
|
||||
? "border-white bg-[#3a3a3a]"
|
||||
: "border-[#3a3a3a] hover:bg-[#3a3a3a]",
|
||||
)}
|
||||
>
|
||||
<Typography.Text className="text-sm font-medium text-content">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { useCancelSubscription } from "#/hooks/mutation/use-cancel-subscription";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
|
||||
interface CancelSubscriptionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function CancelSubscriptionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
endDate,
|
||||
}: CancelSubscriptionModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const cancelSubscriptionMutation = useCancelSubscription();
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
try {
|
||||
await cancelSubscriptionMutation.mutateAsync();
|
||||
displaySuccessToast(t(I18nKey.PAYMENT$SUBSCRIPTION_CANCELLED));
|
||||
onClose();
|
||||
} catch {
|
||||
displayErrorToast(t(I18nKey.ERROR$GENERIC));
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
data-testid="cancel-subscription-modal"
|
||||
className="bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-tertiary w-[500px]"
|
||||
>
|
||||
<h3 className="text-xl font-bold">
|
||||
{t(I18nKey.PAYMENT$CANCEL_SUBSCRIPTION_TITLE)}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{endDate ? (
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE_WITH_DATE}
|
||||
values={{ date: endDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
) : (
|
||||
t(I18nKey.PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE)
|
||||
)}
|
||||
</p>
|
||||
<div className="w-full flex gap-2 mt-2">
|
||||
<BrandButton
|
||||
testId="confirm-cancel-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
onClick={handleCancelSubscription}
|
||||
isDisabled={cancelSubscriptionMutation.isPending}
|
||||
>
|
||||
{t(I18nKey.BUTTON$CONFIRM)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="modal-cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={onClose}
|
||||
isDisabled={cancelSubscriptionMutation.isPending}
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
export const useCreateSubscriptionCheckoutSession = () =>
|
||||
useMutation({
|
||||
mutationFn: BillingService.createSubscriptionCheckoutSession,
|
||||
onSuccess: (data) => {
|
||||
if (data.redirect_url) {
|
||||
window.location.href = data.redirect_url;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
export const useCancelSubscription = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: BillingService.cancelSubscription,
|
||||
onSuccess: () => {
|
||||
// Invalidate subscription access query to refresh the UI
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["user", "subscription_access"],
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
type SubmitOnboardingArgs = {
|
||||
selections: Record<string, string>;
|
||||
};
|
||||
|
||||
export const useSubmitOnboarding = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ selections }: SubmitOnboardingArgs) =>
|
||||
// TODO: mark onboarding as complete
|
||||
// TODO: persist user responses
|
||||
({ selections }),
|
||||
onSuccess: () => {
|
||||
const finalRedirectUrl = "/"; // TODO: use redirect url from api response
|
||||
// Check if the redirect URL is an external URL (starts with http or https)
|
||||
if (
|
||||
finalRedirectUrl.startsWith("http://") ||
|
||||
finalRedirectUrl.startsWith("https://")
|
||||
) {
|
||||
// For external URLs, redirect using window.location
|
||||
window.location.href = finalRedirectUrl;
|
||||
} else {
|
||||
// For internal routes, use navigate
|
||||
navigate(finalRedirectUrl);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
displayErrorToast(error.message);
|
||||
window.location.href = "/";
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
|
||||
|
||||
export const useConfig = () => {
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
const isOnIntermediatePage = useIsOnIntermediatePage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["web-client-config"],
|
||||
queryFn: OptionService.getConfig,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes,
|
||||
enabled: !isOnTosPage,
|
||||
enabled: !isOnIntermediatePage,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
|
||||
|
||||
export const useIsAuthed = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
const isOnIntermediatePage = useIsOnIntermediatePage();
|
||||
|
||||
const appMode = config?.app_mode;
|
||||
|
||||
@@ -29,7 +29,7 @@ export const useIsAuthed = () => {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled: !!appMode && !isOnTosPage,
|
||||
enabled: !!appMode && !isOnIntermediatePage,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
retry: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import SettingsService from "#/api/settings-service/settings-service.api";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
|
||||
import { Settings } from "#/types/settings";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
@@ -22,7 +22,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
const isOnIntermediatePage = useIsOnIntermediatePage();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
const query = useQuery({
|
||||
@@ -35,7 +35,7 @@ export const useSettings = () => {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: !isOnTosPage && !!userIsAuthenticated,
|
||||
enabled: !isOnIntermediatePage && !!userIsAuthenticated,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
|
||||
export const useSubscriptionAccess = () => {
|
||||
const { data: config } = useConfig();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "subscription_access"],
|
||||
queryFn: BillingService.getSubscriptionAccess,
|
||||
enabled:
|
||||
!isOnTosPage &&
|
||||
config?.app_mode === "saas" &&
|
||||
config?.feature_flags?.enable_billing,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
const INTERMEDIATE_PAGE_PATHS = ["/accept-tos", "/onboarding"];
|
||||
|
||||
/**
|
||||
* Checks if the current page is an intermediate page.
|
||||
*
|
||||
* This hook is reusable for all intermediate pages. To add a new intermediate page,
|
||||
* add its path to INTERMEDIATE_PAGE_PATHS array.
|
||||
*/
|
||||
export const useIsOnIntermediatePage = (): boolean => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return INTERMEDIATE_PAGE_PATHS.includes(
|
||||
pathname as (typeof INTERMEDIATE_PAGE_PATHS)[number],
|
||||
);
|
||||
};
|
||||
@@ -105,6 +105,23 @@ export const useTracking = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const trackOnboardingCompleted = ({
|
||||
role,
|
||||
orgSize,
|
||||
useCase,
|
||||
}: {
|
||||
role: string;
|
||||
orgSize: string;
|
||||
useCase: string;
|
||||
}) => {
|
||||
posthog.capture("onboarding_completed", {
|
||||
role,
|
||||
org_size: orgSize,
|
||||
use_case: useCase,
|
||||
...commonProperties,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
trackLoginButtonClick,
|
||||
trackConversationCreated,
|
||||
@@ -116,5 +133,6 @@ export const useTracking = () => {
|
||||
trackCreditsPurchased,
|
||||
trackCreditLimitReached,
|
||||
trackAddTeamMembersButtonClick,
|
||||
trackOnboardingCompleted,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -574,10 +574,7 @@ export enum I18nKey {
|
||||
PAYMENT$MANAGE_CREDITS = "PAYMENT$MANAGE_CREDITS",
|
||||
PAYMENT$CANCEL_SUBSCRIPTION = "PAYMENT$CANCEL_SUBSCRIPTION",
|
||||
PAYMENT$CANCEL_SUBSCRIPTION_TITLE = "PAYMENT$CANCEL_SUBSCRIPTION_TITLE",
|
||||
PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE = "PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE",
|
||||
PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE_WITH_DATE = "PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE_WITH_DATE",
|
||||
PAYMENT$SUBSCRIPTION_CANCELLED = "PAYMENT$SUBSCRIPTION_CANCELLED",
|
||||
PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES = "PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES",
|
||||
PAYMENT$NEXT_BILLING_DATE = "PAYMENT$NEXT_BILLING_DATE",
|
||||
WAITLIST$IF_NOT_JOINED = "WAITLIST$IF_NOT_JOINED",
|
||||
WAITLIST$PATIENCE_MESSAGE = "WAITLIST$PATIENCE_MESSAGE",
|
||||
@@ -767,6 +764,7 @@ export enum I18nKey {
|
||||
AUTH$RECAPTCHA_BLOCKED = "AUTH$RECAPTCHA_BLOCKED",
|
||||
AUTH$LETS_GET_STARTED = "AUTH$LETS_GET_STARTED",
|
||||
AUTH$INVITATION_PENDING = "AUTH$INVITATION_PENDING",
|
||||
AUTH$BITBUCKET_SIGNUP_DISABLED = "AUTH$BITBUCKET_SIGNUP_DISABLED",
|
||||
COMMON$TERMS_OF_SERVICE = "COMMON$TERMS_OF_SERVICE",
|
||||
COMMON$AND = "COMMON$AND",
|
||||
COMMON$PRIVACY_POLICY = "COMMON$PRIVACY_POLICY",
|
||||
@@ -1013,4 +1011,29 @@ export enum I18nKey {
|
||||
CONVERSATION$NO_HISTORY_AVAILABLE = "CONVERSATION$NO_HISTORY_AVAILABLE",
|
||||
CONVERSATION$SHARED_CONVERSATION = "CONVERSATION$SHARED_CONVERSATION",
|
||||
CONVERSATION$LINK_COPIED = "CONVERSATION$LINK_COPIED",
|
||||
ONBOARDING$STEP1_TITLE = "ONBOARDING$STEP1_TITLE",
|
||||
ONBOARDING$STEP1_SUBTITLE = "ONBOARDING$STEP1_SUBTITLE",
|
||||
ONBOARDING$SOFTWARE_ENGINEER = "ONBOARDING$SOFTWARE_ENGINEER",
|
||||
ONBOARDING$ENGINEERING_MANAGER = "ONBOARDING$ENGINEERING_MANAGER",
|
||||
ONBOARDING$CTO_FOUNDER = "ONBOARDING$CTO_FOUNDER",
|
||||
ONBOARDING$PRODUCT_OPERATIONS = "ONBOARDING$PRODUCT_OPERATIONS",
|
||||
ONBOARDING$STUDENT_HOBBYIST = "ONBOARDING$STUDENT_HOBBYIST",
|
||||
ONBOARDING$OTHER = "ONBOARDING$OTHER",
|
||||
ONBOARDING$STEP2_TITLE = "ONBOARDING$STEP2_TITLE",
|
||||
ONBOARDING$SOLO = "ONBOARDING$SOLO",
|
||||
ONBOARDING$ORG_2_10 = "ONBOARDING$ORG_2_10",
|
||||
ONBOARDING$ORG_11_50 = "ONBOARDING$ORG_11_50",
|
||||
ONBOARDING$ORG_51_200 = "ONBOARDING$ORG_51_200",
|
||||
ONBOARDING$ORG_200_1000 = "ONBOARDING$ORG_200_1000",
|
||||
ONBOARDING$ORG_1000_PLUS = "ONBOARDING$ORG_1000_PLUS",
|
||||
ONBOARDING$STEP3_TITLE = "ONBOARDING$STEP3_TITLE",
|
||||
ONBOARDING$NEW_FEATURES = "ONBOARDING$NEW_FEATURES",
|
||||
ONBOARDING$APP_FROM_SCRATCH = "ONBOARDING$APP_FROM_SCRATCH",
|
||||
ONBOARDING$FIXING_BUGS = "ONBOARDING$FIXING_BUGS",
|
||||
ONBOARDING$REFACTORING = "ONBOARDING$REFACTORING",
|
||||
ONBOARDING$AUTOMATING_TASKS = "ONBOARDING$AUTOMATING_TASKS",
|
||||
ONBOARDING$NOT_SURE = "ONBOARDING$NOT_SURE",
|
||||
ONBOARDING$NEXT_BUTTON = "ONBOARDING$NEXT_BUTTON",
|
||||
ONBOARDING$BACK_BUTTON = "ONBOARDING$BACK_BUTTON",
|
||||
ONBOARDING$FINISH_BUTTON = "ONBOARDING$FINISH_BUTTON",
|
||||
}
|
||||
|
||||
@@ -9183,38 +9183,7 @@
|
||||
"de": "Abonnement kündigen",
|
||||
"uk": "Скасувати підписку"
|
||||
},
|
||||
"PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE": {
|
||||
"en": "Are you sure you want to cancel your subscription? You will lose access to premium features at the end of your current billing period.",
|
||||
"ja": "サブスクリプションをキャンセルしてもよろしいですか?現在の請求期間の終了時にプレミアム機能へのアクセスを失います。",
|
||||
"zh-CN": "您确定要取消订阅吗?您将在当前计费周期结束时失去对高级功能的访问权限。",
|
||||
"zh-TW": "您確定要取消訂閱嗎?您將在當前計費週期結束時失去對高級功能的訪問權限。",
|
||||
"ko-KR": "구독을 취소하시겠습니까? 현재 결제 기간이 끝나면 프리미엄 기능에 대한 액세스를 잃게 됩니다.",
|
||||
"no": "Er du sikker på at du vil avbryte abonnementet? Du vil miste tilgang til premium-funksjoner ved slutten av gjeldende faktureringsperiode.",
|
||||
"it": "Sei sicuro di voler annullare l'abbonamento? Perderai l'accesso alle funzionalità premium alla fine del periodo di fatturazione corrente.",
|
||||
"pt": "Tem certeza de que deseja cancelar sua assinatura? Você perderá o acesso aos recursos premium no final do período de cobrança atual.",
|
||||
"es": "¿Estás seguro de que quieres cancelar tu suscripción? Perderás el acceso a las funciones premium al final de tu período de facturación actual.",
|
||||
"ar": "هل أنت متأكد من أنك تريد إلغاء اشتراكك؟ ستفقد الوصول إلى الميزات المميزة في نهاية فترة الفوترة الحالية.",
|
||||
"fr": "Êtes-vous sûr de vouloir annuler votre abonnement ? Vous perdrez l'accès aux fonctionnalités premium à la fin de votre période de facturation actuelle.",
|
||||
"tr": "Aboneliğinizi iptal etmek istediğinizden emin misiniz? Mevcut faturalama döneminizin sonunda premium özelliklere erişiminizi kaybedeceksiniz.",
|
||||
"de": "Sind Sie sicher, dass Sie Ihr Abonnement kündigen möchten? Sie verlieren den Zugang zu Premium-Funktionen am Ende Ihres aktuellen Abrechnungszeitraums.",
|
||||
"uk": "Ві впевнені, що хочете скасувати підписку? Ви втратите доступ до преміум-функцій наприкінці поточного розрахункового періоду."
|
||||
},
|
||||
"PAYMENT$CANCEL_SUBSCRIPTION_MESSAGE_WITH_DATE": {
|
||||
"en": "Are you sure you want to cancel your subscription? You will lose access to premium features on {{date}}.",
|
||||
"ja": "サブスクリプションをキャンセルしてもよろしいですか?{{date}}にプレミアム機能へのアクセスを失います。",
|
||||
"zh-CN": "您确定要取消订阅吗?您将在{{date}}失去对高级功能的访问权限。",
|
||||
"zh-TW": "您確定要取消訂閱嗎?您將在{{date}}失去對高級功能的訪問權限。",
|
||||
"ko-KR": "구독을 취소하시겠습니까? {{date}}에 프리미엄 기능에 대한 액세스를 잃게 됩니다.",
|
||||
"no": "Er du sikker på at du vil avbryte abonnementet? Du vil miste tilgang til premium-funksjoner {{date}}.",
|
||||
"it": "Sei sicuro di voler annullare l'abbonamento? Perderai l'accesso alle funzionalità premium il {{date}}.",
|
||||
"pt": "Tem certeza de que deseja cancelar sua assinatura? Você perderá o acesso aos recursos premium em {{date}}.",
|
||||
"es": "¿Estás seguro de que quieres cancelar tu suscripción? Perderás el acceso a las funciones premium el {{date}}.",
|
||||
"ar": "هل أنت متأكد من أنك تريد إلغاء اشتراكك؟ ستفقد الوصول إلى الميزات المميزة في {{date}}.",
|
||||
"fr": "Êtes-vous sûr de vouloir annuler votre abonnement ? Vous perdrez l'accès aux fonctionnalités premium le {{date}}.",
|
||||
"tr": "Aboneliğinizi iptal etmek istediğinizden emin misiniz? {{date}} tarihinde premium özelliklere erişiminizi kaybedeceksiniz.",
|
||||
"de": "Sind Sie sicher, dass Sie Ihr Abonnement kündigen möchten? Sie verlieren den Zugang zu Premium-Funktionen am {{date}}.",
|
||||
"uk": "Ви впевнені, що хочете скасувати підписку? Ви втратите доступ до преміум-функцій {{date}}."
|
||||
},
|
||||
|
||||
"PAYMENT$SUBSCRIPTION_CANCELLED": {
|
||||
"en": "Subscription cancelled successfully",
|
||||
"ja": "サブスクリプションが正常にキャンセルされました",
|
||||
@@ -9231,22 +9200,7 @@
|
||||
"de": "Abonnement erfolgreich gekündigt",
|
||||
"uk": "Підписку успішно скасовано"
|
||||
},
|
||||
"PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES": {
|
||||
"en": "Your subscription has been cancelled and will expire on {{date}}",
|
||||
"ja": "サブスクリプションはキャンセルされ、{{date}}に期限切れになります",
|
||||
"zh-CN": "您的订阅已取消,将于{{date}}到期",
|
||||
"zh-TW": "您的訂閱已取消,將於{{date}}到期",
|
||||
"ko-KR": "구독이 취소되었으며 {{date}}에 만료됩니다",
|
||||
"no": "Abonnementet ditt er avbrutt og utløper {{date}}",
|
||||
"it": "Il tuo abbonamento è stato annullato e scadrà il {{date}}",
|
||||
"pt": "Sua assinatura foi cancelada e expirará em {{date}}",
|
||||
"es": "Tu suscripción ha sido cancelada y expirará el {{date}}",
|
||||
"ar": "تم إلغاء اشتراكك وسينتهي في {{date}}",
|
||||
"fr": "Votre abonnement a été annulé et expirera le {{date}}",
|
||||
"tr": "Aboneliğiniz iptal edildi ve {{date}} tarihinde sona erecek",
|
||||
"de": "Ihr Abonnement wurde gekündigt und läuft am {{date}} ab",
|
||||
"uk": "Ваша підписка скасована і закінчиться {{date}}"
|
||||
},
|
||||
|
||||
"PAYMENT$NEXT_BILLING_DATE": {
|
||||
"en": "Next billing date: {{date}}",
|
||||
"ja": "次回請求日: {{date}}",
|
||||
@@ -12271,6 +12225,22 @@
|
||||
"de": "Melden Sie sich an, um Ihre Organisationseinladung anzunehmen",
|
||||
"uk": "Увійдіть, щоб прийняти запрошення до організації"
|
||||
},
|
||||
"AUTH$BITBUCKET_SIGNUP_DISABLED": {
|
||||
"en": "OpenHands Cloud has temporarily disabled Bitbucket registrations and is only accepting logins from existing users at this time. We recommend registering with GitHub or GitLab instead. We are sorry for the inconvenience.",
|
||||
"ja": "OpenHands Cloudは現在、Bitbucketでの新規登録を一時的に停止しており、既存ユーザーのログインのみ受け付けています。GitHubまたはGitLabでの登録をお勧めします。ご不便をおかけして申し訳ありません。",
|
||||
"zh-CN": "OpenHands Cloud 暂时禁用了 Bitbucket 注册,目前仅接受现有用户登录。我们建议使用 GitHub 或 GitLab 注册。对此带来的不便,我们深表歉意。",
|
||||
"zh-TW": "OpenHands Cloud 暫時停用了 Bitbucket 註冊,目前僅接受現有用戶登入。我們建議使用 GitHub 或 GitLab 註冊。對此帶來的不便,我們深表歉意。",
|
||||
"ko-KR": "OpenHands Cloud는 현재 Bitbucket 등록을 일시적으로 비활성화했으며, 기존 사용자의 로그인만 허용하고 있습니다. GitHub 또는 GitLab으로 등록하시는 것을 권장합니다. 불편을 드려 죄송합니다.",
|
||||
"no": "OpenHands Cloud har midlertidig deaktivert Bitbucket-registreringer og aksepterer kun pålogging fra eksisterende brukere for øyeblikket. Vi anbefaler å registrere deg med GitHub eller GitLab i stedet. Vi beklager ulempen.",
|
||||
"it": "OpenHands Cloud ha temporaneamente disabilitato le registrazioni Bitbucket e al momento accetta solo accessi da utenti esistenti. Ti consigliamo di registrarti con GitHub o GitLab. Ci scusiamo per l'inconveniente.",
|
||||
"pt": "O OpenHands Cloud desativou temporariamente os registros do Bitbucket e está aceitando apenas logins de usuários existentes no momento. Recomendamos registrar-se com GitHub ou GitLab. Pedimos desculpas pelo inconveniente.",
|
||||
"es": "OpenHands Cloud ha deshabilitado temporalmente los registros de Bitbucket y solo acepta inicios de sesión de usuarios existentes en este momento. Recomendamos registrarse con GitHub o GitLab en su lugar. Lamentamos las molestias.",
|
||||
"ar": "قام OpenHands Cloud بتعطيل تسجيلات Bitbucket مؤقتًا ويقبل فقط تسجيل الدخول من المستخدمين الحاليين في الوقت الحالي. نوصي بالتسجيل باستخدام GitHub أو GitLab بدلاً من ذلك. نعتذر عن أي إزعاج.",
|
||||
"fr": "OpenHands Cloud a temporairement désactivé les inscriptions Bitbucket et n'accepte actuellement que les connexions des utilisateurs existants. Nous vous recommandons de vous inscrire avec GitHub ou GitLab à la place. Nous nous excusons pour la gêne occasionnée.",
|
||||
"tr": "OpenHands Cloud, Bitbucket kayıtlarını geçici olarak devre dışı bıraktı ve şu anda yalnızca mevcut kullanıcıların girişlerini kabul ediyor. Bunun yerine GitHub veya GitLab ile kayıt olmanızı öneririz. Verdiğimiz rahatsızlık için özür dileriz.",
|
||||
"de": "OpenHands Cloud hat Bitbucket-Registrierungen vorübergehend deaktiviert und akzeptiert derzeit nur Anmeldungen von bestehenden Benutzern. Wir empfehlen, sich stattdessen mit GitHub oder GitLab zu registrieren. Wir entschuldigen uns für die Unannehmlichkeiten.",
|
||||
"uk": "OpenHands Cloud тимчасово вимкнув реєстрацію через Bitbucket і наразі приймає лише вхід існуючих користувачів. Рекомендуємо зареєструватися через GitHub або GitLab. Вибачте за незручності."
|
||||
},
|
||||
"COMMON$TERMS_OF_SERVICE": {
|
||||
"en": "Terms of Service",
|
||||
"ja": "利用規約",
|
||||
@@ -16206,5 +16176,405 @@
|
||||
"tr": "Bağlantı panoya kopyalandı",
|
||||
"de": "Link in die Zwischenablage kopiert",
|
||||
"uk": "Посилання скопійовано в буфер обміну"
|
||||
},
|
||||
"ONBOARDING$STEP1_TITLE": {
|
||||
"en": "What's your role?",
|
||||
"ja": "あなたの役割は?",
|
||||
"zh-CN": "您的角色是什么?",
|
||||
"zh-TW": "您的角色是什麼?",
|
||||
"ko-KR": "귀하의 역할은 무엇입니까?",
|
||||
"no": "Hva er din rolle?",
|
||||
"ar": "ما هو دورك؟",
|
||||
"de": "Was ist Ihre Rolle?",
|
||||
"fr": "Quel est votre rôle ?",
|
||||
"it": "Qual è il tuo ruolo?",
|
||||
"pt": "Qual é o seu papel?",
|
||||
"es": "¿Cuál es tu rol?",
|
||||
"tr": "Rolünüz nedir?",
|
||||
"uk": "Яка ваша роль?"
|
||||
},
|
||||
"ONBOARDING$STEP1_SUBTITLE": {
|
||||
"en": "Select the option that best fits you",
|
||||
"ja": "最も当てはまるオプションを選択してください",
|
||||
"zh-CN": "选择最适合您的选项",
|
||||
"zh-TW": "選擇最適合您的選項",
|
||||
"ko-KR": "가장 적합한 옵션을 선택하세요",
|
||||
"no": "Velg alternativet som passer deg best",
|
||||
"ar": "اختر الخيار الأنسب لك",
|
||||
"de": "Wählen Sie die Option, die am besten zu Ihnen passt",
|
||||
"fr": "Sélectionnez l'option qui vous convient le mieux",
|
||||
"it": "Seleziona l'opzione più adatta a te",
|
||||
"pt": "Selecione a opção que melhor se adapta a você",
|
||||
"es": "Selecciona la opción que mejor te describa",
|
||||
"tr": "Size en uygun seçeneği seçin",
|
||||
"uk": "Виберіть варіант, який найкраще вам підходить"
|
||||
},
|
||||
"ONBOARDING$SOFTWARE_ENGINEER": {
|
||||
"en": "Software engineer / developer",
|
||||
"ja": "ソフトウェアエンジニア / 開発者",
|
||||
"zh-CN": "软件工程师 / 开发者",
|
||||
"zh-TW": "軟體工程師 / 開發者",
|
||||
"ko-KR": "소프트웨어 엔지니어 / 개발자",
|
||||
"no": "Programvareingeniør / utvikler",
|
||||
"ar": "مهندس برمجيات / مطور",
|
||||
"de": "Softwareentwickler / Entwickler",
|
||||
"fr": "Ingénieur logiciel / développeur",
|
||||
"it": "Ingegnere software / sviluppatore",
|
||||
"pt": "Engenheiro de software / desenvolvedor",
|
||||
"es": "Ingeniero de software / desarrollador",
|
||||
"tr": "Yazılım mühendisi / geliştirici",
|
||||
"uk": "Програмний інженер / розробник"
|
||||
},
|
||||
"ONBOARDING$ENGINEERING_MANAGER": {
|
||||
"en": "Engineering manager / tech lead",
|
||||
"ja": "エンジニアリングマネージャー / テックリード",
|
||||
"zh-CN": "工程经理 / 技术负责人",
|
||||
"zh-TW": "工程經理 / 技術負責人",
|
||||
"ko-KR": "엔지니어링 매니저 / 테크 리드",
|
||||
"no": "Ingeniørsjef / teknisk leder",
|
||||
"ar": "مدير هندسة / قائد تقني",
|
||||
"de": "Engineering Manager / Tech Lead",
|
||||
"fr": "Responsable ingénierie / tech lead",
|
||||
"it": "Engineering manager / tech lead",
|
||||
"pt": "Gerente de engenharia / tech lead",
|
||||
"es": "Gerente de ingeniería / tech lead",
|
||||
"tr": "Mühendislik müdürü / teknik lider",
|
||||
"uk": "Менеджер з розробки / технічний лідер"
|
||||
},
|
||||
"ONBOARDING$CTO_FOUNDER": {
|
||||
"en": "CTO / founder",
|
||||
"ja": "CTO / 創業者",
|
||||
"zh-CN": "CTO / 创始人",
|
||||
"zh-TW": "CTO / 創辦人",
|
||||
"ko-KR": "CTO / 창업자",
|
||||
"no": "CTO / grunnlegger",
|
||||
"ar": "مدير التكنولوجيا / مؤسس",
|
||||
"de": "CTO / Gründer",
|
||||
"fr": "CTO / fondateur",
|
||||
"it": "CTO / fondatore",
|
||||
"pt": "CTO / fundador",
|
||||
"es": "CTO / fundador",
|
||||
"tr": "CTO / kurucu",
|
||||
"uk": "CTO / засновник"
|
||||
},
|
||||
"ONBOARDING$PRODUCT_OPERATIONS": {
|
||||
"en": "Product or operations role",
|
||||
"ja": "プロダクトまたはオペレーションの役割",
|
||||
"zh-CN": "产品或运营角色",
|
||||
"zh-TW": "產品或營運角色",
|
||||
"ko-KR": "제품 또는 운영 역할",
|
||||
"no": "Produkt- eller driftsrolle",
|
||||
"ar": "دور المنتج أو العمليات",
|
||||
"de": "Produkt- oder Betriebsrolle",
|
||||
"fr": "Rôle produit ou opérations",
|
||||
"it": "Ruolo prodotto o operazioni",
|
||||
"pt": "Função de produto ou operações",
|
||||
"es": "Rol de producto u operaciones",
|
||||
"tr": "Ürün veya operasyon rolü",
|
||||
"uk": "Роль продукту або операцій"
|
||||
},
|
||||
"ONBOARDING$STUDENT_HOBBYIST": {
|
||||
"en": "Student / hobbyist",
|
||||
"ja": "学生 / 趣味",
|
||||
"zh-CN": "学生 / 爱好者",
|
||||
"zh-TW": "學生 / 愛好者",
|
||||
"ko-KR": "학생 / 취미",
|
||||
"no": "Student / hobbyist",
|
||||
"ar": "طالب / هاوٍ",
|
||||
"de": "Student / Hobbyist",
|
||||
"fr": "Étudiant / amateur",
|
||||
"it": "Studente / hobbista",
|
||||
"pt": "Estudante / hobbyista",
|
||||
"es": "Estudiante / aficionado",
|
||||
"tr": "Öğrenci / hobi",
|
||||
"uk": "Студент / хобіст"
|
||||
},
|
||||
"ONBOARDING$OTHER": {
|
||||
"en": "Other",
|
||||
"ja": "その他",
|
||||
"zh-CN": "其他",
|
||||
"zh-TW": "其他",
|
||||
"ko-KR": "기타",
|
||||
"no": "Annet",
|
||||
"ar": "أخرى",
|
||||
"de": "Andere",
|
||||
"fr": "Autre",
|
||||
"it": "Altro",
|
||||
"pt": "Outro",
|
||||
"es": "Otro",
|
||||
"tr": "Diğer",
|
||||
"uk": "Інше"
|
||||
},
|
||||
"ONBOARDING$STEP2_TITLE": {
|
||||
"en": "What size organization do you work for?",
|
||||
"ja": "どのくらいの規模の組織で働いていますか?",
|
||||
"zh-CN": "您所在的组织规模是多大?",
|
||||
"zh-TW": "您所在的組織規模是多大?",
|
||||
"ko-KR": "어느 규모의 조직에서 일하고 계십니까?",
|
||||
"no": "Hvor stor organisasjon jobber du for?",
|
||||
"ar": "ما حجم المنظمة التي تعمل بها؟",
|
||||
"de": "Für welche Unternehmensgröße arbeiten Sie?",
|
||||
"fr": "Quelle est la taille de votre organisation ?",
|
||||
"it": "Per quale dimensione di organizzazione lavori?",
|
||||
"pt": "Qual o tamanho da organização em que você trabalha?",
|
||||
"es": "¿De qué tamaño es la organización para la que trabajas?",
|
||||
"tr": "Hangi büyüklükte bir organizasyon için çalışıyorsunuz?",
|
||||
"uk": "Якого розміру організація, в якій ви працюєте?"
|
||||
},
|
||||
"ONBOARDING$SOLO": {
|
||||
"en": "Just me (solo)",
|
||||
"ja": "自分だけ(ソロ)",
|
||||
"zh-CN": "只有我(个人)",
|
||||
"zh-TW": "只有我(個人)",
|
||||
"ko-KR": "저만 (개인)",
|
||||
"no": "Bare meg (solo)",
|
||||
"ar": "أنا فقط (منفرد)",
|
||||
"de": "Nur ich (solo)",
|
||||
"fr": "Juste moi (solo)",
|
||||
"it": "Solo io (individuale)",
|
||||
"pt": "Apenas eu (solo)",
|
||||
"es": "Solo yo (individual)",
|
||||
"tr": "Sadece ben (solo)",
|
||||
"uk": "Тільки я (соло)"
|
||||
},
|
||||
"ONBOARDING$ORG_2_10": {
|
||||
"en": "2–10 people",
|
||||
"ja": "2〜10人",
|
||||
"zh-CN": "2-10人",
|
||||
"zh-TW": "2-10人",
|
||||
"ko-KR": "2-10명",
|
||||
"no": "2–10 personer",
|
||||
"ar": "2-10 أشخاص",
|
||||
"de": "2–10 Personen",
|
||||
"fr": "2–10 personnes",
|
||||
"it": "2–10 persone",
|
||||
"pt": "2–10 pessoas",
|
||||
"es": "2–10 personas",
|
||||
"tr": "2–10 kişi",
|
||||
"uk": "2–10 осіб"
|
||||
},
|
||||
"ONBOARDING$ORG_11_50": {
|
||||
"en": "11–50 people",
|
||||
"ja": "11〜50人",
|
||||
"zh-CN": "11-50人",
|
||||
"zh-TW": "11-50人",
|
||||
"ko-KR": "11-50명",
|
||||
"no": "11–50 personer",
|
||||
"ar": "11-50 شخصاً",
|
||||
"de": "11–50 Personen",
|
||||
"fr": "11–50 personnes",
|
||||
"it": "11–50 persone",
|
||||
"pt": "11–50 pessoas",
|
||||
"es": "11–50 personas",
|
||||
"tr": "11–50 kişi",
|
||||
"uk": "11–50 осіб"
|
||||
},
|
||||
"ONBOARDING$ORG_51_200": {
|
||||
"en": "51–200 people",
|
||||
"ja": "51〜200人",
|
||||
"zh-CN": "51-200人",
|
||||
"zh-TW": "51-200人",
|
||||
"ko-KR": "51-200명",
|
||||
"no": "51–200 personer",
|
||||
"ar": "51-200 شخصاً",
|
||||
"de": "51–200 Personen",
|
||||
"fr": "51–200 personnes",
|
||||
"it": "51–200 persone",
|
||||
"pt": "51–200 pessoas",
|
||||
"es": "51–200 personas",
|
||||
"tr": "51–200 kişi",
|
||||
"uk": "51–200 осіб"
|
||||
},
|
||||
"ONBOARDING$ORG_200_1000": {
|
||||
"en": "200–1000 people",
|
||||
"ja": "200〜1000人",
|
||||
"zh-CN": "200-1000人",
|
||||
"zh-TW": "200-1000人",
|
||||
"ko-KR": "200-1000명",
|
||||
"no": "200–1000 personer",
|
||||
"ar": "200-1000 شخص",
|
||||
"de": "200–1000 Personen",
|
||||
"fr": "200–1000 personnes",
|
||||
"it": "200–1000 persone",
|
||||
"pt": "200–1000 pessoas",
|
||||
"es": "200–1000 personas",
|
||||
"tr": "200–1000 kişi",
|
||||
"uk": "200–1000 осіб"
|
||||
},
|
||||
"ONBOARDING$ORG_1000_PLUS": {
|
||||
"en": "1000+ people",
|
||||
"ja": "1000人以上",
|
||||
"zh-CN": "1000+人",
|
||||
"zh-TW": "1000+人",
|
||||
"ko-KR": "1000명 이상",
|
||||
"no": "1000+ personer",
|
||||
"ar": "أكثر من 1000 شخص",
|
||||
"de": "1000+ Personen",
|
||||
"fr": "1000+ personnes",
|
||||
"it": "1000+ persone",
|
||||
"pt": "1000+ pessoas",
|
||||
"es": "1000+ personas",
|
||||
"tr": "1000+ kişi",
|
||||
"uk": "1000+ осіб"
|
||||
},
|
||||
"ONBOARDING$STEP3_TITLE": {
|
||||
"en": "What use cases are you looking to use OpenHands for?",
|
||||
"ja": "OpenHandsをどのような用途で使用したいですか?",
|
||||
"zh-CN": "您希望将 OpenHands 用于哪些场景?",
|
||||
"zh-TW": "您希望將 OpenHands 用於哪些場景?",
|
||||
"ko-KR": "OpenHands를 어떤 용도로 사용하시겠습니까?",
|
||||
"no": "Hvilke bruksområder ønsker du å bruke OpenHands til?",
|
||||
"ar": "ما هي حالات الاستخدام التي تريد استخدام OpenHands لها؟",
|
||||
"de": "Für welche Anwendungsfälle möchten Sie OpenHands nutzen?",
|
||||
"fr": "Pour quels cas d'utilisation souhaitez-vous utiliser OpenHands ?",
|
||||
"it": "Per quali casi d'uso vorresti usare OpenHands?",
|
||||
"pt": "Para quais casos de uso você pretende usar o OpenHands?",
|
||||
"es": "¿Para qué casos de uso quieres usar OpenHands?",
|
||||
"tr": "OpenHands'i hangi kullanım alanları için kullanmak istiyorsunuz?",
|
||||
"uk": "Для яких випадків використання ви хочете використовувати OpenHands?"
|
||||
},
|
||||
"ONBOARDING$NEW_FEATURES": {
|
||||
"en": "Writing new features to existing products",
|
||||
"ja": "既存の製品に新機能を追加",
|
||||
"zh-CN": "为现有产品编写新功能",
|
||||
"zh-TW": "為現有產品編寫新功能",
|
||||
"ko-KR": "기존 제품에 새로운 기능 작성",
|
||||
"no": "Skrive nye funksjoner til eksisterende produkter",
|
||||
"ar": "كتابة ميزات جديدة للمنتجات الحالية",
|
||||
"de": "Neue Funktionen für bestehende Produkte schreiben",
|
||||
"fr": "Écrire de nouvelles fonctionnalités pour des produits existants",
|
||||
"it": "Scrivere nuove funzionalità per prodotti esistenti",
|
||||
"pt": "Escrever novos recursos para produtos existentes",
|
||||
"es": "Escribir nuevas funcionalidades para productos existentes",
|
||||
"tr": "Mevcut ürünlere yeni özellikler yazmak",
|
||||
"uk": "Написання нових функцій для існуючих продуктів"
|
||||
},
|
||||
"ONBOARDING$APP_FROM_SCRATCH": {
|
||||
"en": "Starting an app from scratch",
|
||||
"ja": "ゼロからアプリを開発",
|
||||
"zh-CN": "从头开始创建应用",
|
||||
"zh-TW": "從頭開始創建應用",
|
||||
"ko-KR": "처음부터 앱 시작",
|
||||
"no": "Starte en app fra bunnen av",
|
||||
"ar": "بدء تطبيق من الصفر",
|
||||
"de": "Eine App von Grund auf erstellen",
|
||||
"fr": "Démarrer une application à partir de zéro",
|
||||
"it": "Iniziare un'app da zero",
|
||||
"pt": "Iniciar um aplicativo do zero",
|
||||
"es": "Comenzar una aplicación desde cero",
|
||||
"tr": "Sıfırdan bir uygulama başlatmak",
|
||||
"uk": "Створення додатку з нуля"
|
||||
},
|
||||
"ONBOARDING$FIXING_BUGS": {
|
||||
"en": "Fixing bugs",
|
||||
"ja": "バグの修正",
|
||||
"zh-CN": "修复漏洞",
|
||||
"zh-TW": "修復漏洞",
|
||||
"ko-KR": "버그 수정",
|
||||
"no": "Fikse feil",
|
||||
"ar": "إصلاح الأخطاء",
|
||||
"de": "Fehler beheben",
|
||||
"fr": "Corriger des bugs",
|
||||
"it": "Correggere bug",
|
||||
"pt": "Corrigir bugs",
|
||||
"es": "Corregir errores",
|
||||
"tr": "Hataları düzeltmek",
|
||||
"uk": "Виправлення помилок"
|
||||
},
|
||||
"ONBOARDING$REFACTORING": {
|
||||
"en": "Refactoring existing code / eliminating tech debt",
|
||||
"ja": "既存コードのリファクタリング / 技術的負債の解消",
|
||||
"zh-CN": "重构现有代码 / 消除技术债务",
|
||||
"zh-TW": "重構現有代碼 / 消除技術債務",
|
||||
"ko-KR": "기존 코드 리팩토링 / 기술 부채 제거",
|
||||
"no": "Refaktorere eksisterende kode / eliminere teknisk gjeld",
|
||||
"ar": "إعادة هيكلة الكود الحالي / إزالة الديون التقنية",
|
||||
"de": "Bestehenden Code refaktorisieren / technische Schulden abbauen",
|
||||
"fr": "Refactoriser le code existant / éliminer la dette technique",
|
||||
"it": "Refactoring del codice esistente / eliminare il debito tecnico",
|
||||
"pt": "Refatorar código existente / eliminar dívida técnica",
|
||||
"es": "Refactorizar código existente / eliminar deuda técnica",
|
||||
"tr": "Mevcut kodu yeniden düzenlemek / teknik borcu ortadan kaldırmak",
|
||||
"uk": "Рефакторинг існуючого коду / усунення технічного боргу"
|
||||
},
|
||||
"ONBOARDING$AUTOMATING_TASKS": {
|
||||
"en": "Automating repetitive coding tasks",
|
||||
"ja": "繰り返しのコーディング作業の自動化",
|
||||
"zh-CN": "自动化重复性编码任务",
|
||||
"zh-TW": "自動化重複性編碼任務",
|
||||
"ko-KR": "반복적인 코딩 작업 자동화",
|
||||
"no": "Automatisere repetitive kodeoppgaver",
|
||||
"ar": "أتمتة مهام البرمجة المتكررة",
|
||||
"de": "Wiederkehrende Codierungsaufgaben automatisieren",
|
||||
"fr": "Automatiser les tâches de codage répétitives",
|
||||
"it": "Automatizzare attività di codifica ripetitive",
|
||||
"pt": "Automatizar tarefas de codificação repetitivas",
|
||||
"es": "Automatizar tareas de codificación repetitivas",
|
||||
"tr": "Tekrarlayan kodlama görevlerini otomatikleştirmek",
|
||||
"uk": "Автоматизація повторюваних завдань кодування"
|
||||
},
|
||||
"ONBOARDING$NOT_SURE": {
|
||||
"en": "Not sure yet",
|
||||
"ja": "まだ決めていない",
|
||||
"zh-CN": "尚未确定",
|
||||
"zh-TW": "尚未確定",
|
||||
"ko-KR": "아직 모르겠습니다",
|
||||
"no": "Ikke sikker ennå",
|
||||
"ar": "لست متأكداً بعد",
|
||||
"de": "Noch nicht sicher",
|
||||
"fr": "Pas encore sûr",
|
||||
"it": "Non ancora sicuro",
|
||||
"pt": "Ainda não tenho certeza",
|
||||
"es": "Aún no estoy seguro",
|
||||
"tr": "Henüz emin değilim",
|
||||
"uk": "Ще не впевнений"
|
||||
},
|
||||
"ONBOARDING$NEXT_BUTTON": {
|
||||
"en": "Next",
|
||||
"ja": "次へ",
|
||||
"zh-CN": "下一步",
|
||||
"zh-TW": "下一步",
|
||||
"ko-KR": "다음",
|
||||
"no": "Neste",
|
||||
"ar": "التالي",
|
||||
"de": "Weiter",
|
||||
"fr": "Suivant",
|
||||
"it": "Avanti",
|
||||
"pt": "Próximo",
|
||||
"es": "Siguiente",
|
||||
"tr": "İleri",
|
||||
"uk": "Далі"
|
||||
},
|
||||
"ONBOARDING$BACK_BUTTON": {
|
||||
"en": "Back",
|
||||
"ja": "戻る",
|
||||
"zh-CN": "返回",
|
||||
"zh-TW": "返回",
|
||||
"ko-KR": "뒤로",
|
||||
"no": "Tilbake",
|
||||
"ar": "رجوع",
|
||||
"de": "Zurück",
|
||||
"fr": "Retour",
|
||||
"it": "Indietro",
|
||||
"pt": "Voltar",
|
||||
"es": "Atrás",
|
||||
"tr": "Geri",
|
||||
"uk": "Назад"
|
||||
},
|
||||
"ONBOARDING$FINISH_BUTTON": {
|
||||
"en": "Finish",
|
||||
"ja": "完了",
|
||||
"zh-CN": "完成",
|
||||
"zh-TW": "完成",
|
||||
"ko-KR": "완료",
|
||||
"no": "Fullfør",
|
||||
"ar": "إنهاء",
|
||||
"de": "Fertig",
|
||||
"fr": "Terminer",
|
||||
"it": "Fine",
|
||||
"pt": "Concluir",
|
||||
"es": "Finalizar",
|
||||
"tr": "Bitir",
|
||||
"uk": "Завершити"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +1,22 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { SubscriptionAccess } from "#/api/billing-service/billing.types";
|
||||
|
||||
// Mock data for different subscription scenarios
|
||||
const MOCK_ACTIVE_SUBSCRIPTION: SubscriptionAccess = {
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2024-12-31T23:59:59Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: null,
|
||||
stripe_subscription_id: "sub_mock123456789",
|
||||
};
|
||||
export const BILLING_HANDLERS = [
|
||||
http.get("/api/billing/credits", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({ credits: "100" });
|
||||
}),
|
||||
|
||||
const MOCK_CANCELLED_SUBSCRIPTION: SubscriptionAccess = {
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T23:59:59Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: "2024-06-15T10:30:00Z",
|
||||
stripe_subscription_id: "sub_mock123456789",
|
||||
};
|
||||
http.post("/api/billing/create-checkout-session", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({
|
||||
redirect_url: "https://stripe.com/some-checkout",
|
||||
});
|
||||
}),
|
||||
|
||||
// Expired subscription (end_at < now) - will be filtered out by backend logic
|
||||
const MOCK_EXPIRED_SUBSCRIPTION: SubscriptionAccess = {
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2024-06-01T00:00:00Z", // Expired
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: null,
|
||||
stripe_subscription_id: "sub_mock123456789",
|
||||
};
|
||||
|
||||
// Helper function to check if subscription is currently active (matches backend logic)
|
||||
function isSubscriptionActive(
|
||||
subscription: SubscriptionAccess | null,
|
||||
): boolean {
|
||||
if (!subscription) return false;
|
||||
|
||||
const now = new Date();
|
||||
const startAt = new Date(subscription.start_at);
|
||||
const endAt = new Date(subscription.end_at);
|
||||
|
||||
// Backend filters: status == 'ACTIVE' AND start_at <= now AND end_at >= now
|
||||
return startAt <= now && endAt >= now;
|
||||
}
|
||||
|
||||
// Factory function to create billing handlers with different subscription states
|
||||
function createBillingHandlers(subscriptionData: SubscriptionAccess | null) {
|
||||
return [
|
||||
http.get("/api/billing/credits", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({ credits: "100" });
|
||||
}),
|
||||
|
||||
http.get("/api/billing/subscription-access", async () => {
|
||||
await delay();
|
||||
// Apply backend filtering logic - only return if subscription is currently active
|
||||
const activeSubscription = isSubscriptionActive(subscriptionData)
|
||||
? subscriptionData
|
||||
: null;
|
||||
return HttpResponse.json(activeSubscription);
|
||||
}),
|
||||
|
||||
http.post("/api/billing/create-checkout-session", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({
|
||||
redirect_url: "https://stripe.com/some-checkout",
|
||||
});
|
||||
}),
|
||||
|
||||
http.post("/api/billing/subscription-checkout-session", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({
|
||||
redirect_url: "https://stripe.com/some-subscription-checkout",
|
||||
});
|
||||
}),
|
||||
|
||||
http.post("/api/billing/create-customer-setup-session", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({
|
||||
redirect_url: "https://stripe.com/some-customer-setup",
|
||||
});
|
||||
}),
|
||||
|
||||
http.post("/api/billing/cancel-subscription", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({
|
||||
status: "success",
|
||||
message: "Subscription cancelled successfully",
|
||||
});
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// Export different handler sets for different testing scenarios
|
||||
export const STRIPE_BILLING_HANDLERS = createBillingHandlers(
|
||||
MOCK_ACTIVE_SUBSCRIPTION,
|
||||
);
|
||||
export const STRIPE_BILLING_HANDLERS_NO_SUBSCRIPTION =
|
||||
createBillingHandlers(null);
|
||||
export const STRIPE_BILLING_HANDLERS_CANCELLED_SUBSCRIPTION =
|
||||
createBillingHandlers(MOCK_CANCELLED_SUBSCRIPTION);
|
||||
export const STRIPE_BILLING_HANDLERS_EXPIRED_SUBSCRIPTION =
|
||||
createBillingHandlers(MOCK_EXPIRED_SUBSCRIPTION); // This will return null due to filtering
|
||||
http.post("/api/billing/create-customer-setup-session", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json({
|
||||
redirect_url: "https://stripe.com/some-customer-setup",
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { API_KEYS_HANDLERS } from "./api-keys-handlers";
|
||||
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
|
||||
import { BILLING_HANDLERS } from "./billing-handlers";
|
||||
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
|
||||
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
|
||||
import { SECRETS_HANDLERS } from "./secrets-handlers";
|
||||
@@ -16,7 +16,7 @@ import { ANALYTICS_HANDLERS } from "./analytics-handlers";
|
||||
|
||||
export const handlers = [
|
||||
...API_KEYS_HANDLERS,
|
||||
...STRIPE_BILLING_HANDLERS,
|
||||
...BILLING_HANDLERS,
|
||||
...FILE_SERVICE_HANDLERS,
|
||||
...TASK_SUGGESTIONS_HANDLERS,
|
||||
...SECRETS_HANDLERS,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
|
||||
export default [
|
||||
route("login", "routes/login.tsx"),
|
||||
route("onboarding", "routes/onboarding-form.tsx"),
|
||||
layout("routes/root-layout.tsx", [
|
||||
index("routes/home.tsx"),
|
||||
route("accept-tos", "routes/accept-tos.tsx"),
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, redirect } from "react-router";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import StepHeader from "#/components/features/onboarding/step-header";
|
||||
import { StepContent } from "#/components/features/onboarding/step-content";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import OpenHandsLogoWhite from "#/assets/branding/openhands-logo-white.svg?react";
|
||||
import { useSubmitOnboarding } from "#/hooks/mutation/use-submit-onboarding";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
import { ENABLE_ONBOARDING } from "#/utils/feature-flags";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
|
||||
export const clientLoader = async () => {
|
||||
const config = await queryClient.ensureQueryData({
|
||||
queryKey: ["config"],
|
||||
queryFn: OptionService.getConfig,
|
||||
});
|
||||
|
||||
if (config.app_mode !== "saas" || !ENABLE_ONBOARDING()) {
|
||||
return redirect("/");
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface StepOption {
|
||||
id: string;
|
||||
labelKey?: I18nKey;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface FormStep {
|
||||
id: string;
|
||||
titleKey: I18nKey;
|
||||
options: StepOption[];
|
||||
}
|
||||
|
||||
const steps: FormStep[] = [
|
||||
{
|
||||
id: "step1",
|
||||
titleKey: I18nKey.ONBOARDING$STEP1_TITLE,
|
||||
options: [
|
||||
{
|
||||
id: "software_engineer",
|
||||
labelKey: I18nKey.ONBOARDING$SOFTWARE_ENGINEER,
|
||||
},
|
||||
{
|
||||
id: "engineering_manager",
|
||||
labelKey: I18nKey.ONBOARDING$ENGINEERING_MANAGER,
|
||||
},
|
||||
{
|
||||
id: "cto_founder",
|
||||
labelKey: I18nKey.ONBOARDING$CTO_FOUNDER,
|
||||
},
|
||||
{
|
||||
id: "product_operations",
|
||||
labelKey: I18nKey.ONBOARDING$PRODUCT_OPERATIONS,
|
||||
},
|
||||
{
|
||||
id: "student_hobbyist",
|
||||
labelKey: I18nKey.ONBOARDING$STUDENT_HOBBYIST,
|
||||
},
|
||||
{
|
||||
id: "other",
|
||||
labelKey: I18nKey.ONBOARDING$OTHER,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "step2",
|
||||
titleKey: I18nKey.ONBOARDING$STEP2_TITLE,
|
||||
options: [
|
||||
{
|
||||
id: "solo",
|
||||
labelKey: I18nKey.ONBOARDING$SOLO,
|
||||
},
|
||||
{
|
||||
id: "org_2_10",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_2_10,
|
||||
},
|
||||
{
|
||||
id: "org_11_50",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_11_50,
|
||||
},
|
||||
{
|
||||
id: "org_51_200",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_51_200,
|
||||
},
|
||||
{
|
||||
id: "org_200_1000",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_200_1000,
|
||||
},
|
||||
{
|
||||
id: "org_1000_plus",
|
||||
labelKey: I18nKey.ONBOARDING$ORG_1000_PLUS,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "step3",
|
||||
titleKey: I18nKey.ONBOARDING$STEP3_TITLE,
|
||||
options: [
|
||||
{
|
||||
id: "new_features",
|
||||
labelKey: I18nKey.ONBOARDING$NEW_FEATURES,
|
||||
},
|
||||
{
|
||||
id: "app_from_scratch",
|
||||
labelKey: I18nKey.ONBOARDING$APP_FROM_SCRATCH,
|
||||
},
|
||||
{
|
||||
id: "fixing_bugs",
|
||||
labelKey: I18nKey.ONBOARDING$FIXING_BUGS,
|
||||
},
|
||||
{
|
||||
id: "refactoring",
|
||||
labelKey: I18nKey.ONBOARDING$REFACTORING,
|
||||
},
|
||||
{
|
||||
id: "automating_tasks",
|
||||
labelKey: I18nKey.ONBOARDING$AUTOMATING_TASKS,
|
||||
},
|
||||
{
|
||||
id: "not_sure",
|
||||
labelKey: I18nKey.ONBOARDING$NOT_SURE,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function OnboardingForm() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { mutate: submitOnboarding } = useSubmitOnboarding();
|
||||
const { trackOnboardingCompleted } = useTracking();
|
||||
|
||||
const [currentStepIndex, setCurrentStepIndex] = React.useState(0);
|
||||
const [selections, setSelections] = React.useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const currentStep = steps[currentStepIndex];
|
||||
const isLastStep = currentStepIndex === steps.length - 1;
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
const currentSelection = selections[currentStep.id] || null;
|
||||
|
||||
const handleSelectOption = (optionId: string) => {
|
||||
setSelections((prev) => ({
|
||||
...prev,
|
||||
[currentStep.id]: optionId,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (isLastStep) {
|
||||
submitOnboarding({ selections });
|
||||
try {
|
||||
trackOnboardingCompleted({
|
||||
role: selections.step1,
|
||||
orgSize: selections.step2,
|
||||
useCase: selections.step3,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to track onboarding:", error);
|
||||
}
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (isFirstStep) {
|
||||
navigate(-1);
|
||||
} else {
|
||||
setCurrentStepIndex((prev) => prev - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const translatedOptions = currentStep.options.map((option) => ({
|
||||
id: option.id,
|
||||
label: option.labelKey ? t(option.labelKey) : option.label!,
|
||||
}));
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<div
|
||||
data-testid="onboarding-form"
|
||||
className="w-[500px] max-w-[calc(100vw-2rem)] mx-auto p-4 sm:p-6 flex flex-col justify-center overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<OpenHandsLogoWhite width={55} height={55} />
|
||||
</div>
|
||||
<StepHeader
|
||||
title={t(currentStep.titleKey)}
|
||||
currentStep={currentStepIndex + 1}
|
||||
totalSteps={steps.length}
|
||||
/>
|
||||
<StepContent
|
||||
options={translatedOptions}
|
||||
selectedOptionId={currentSelection}
|
||||
onSelectOption={handleSelectOption}
|
||||
/>
|
||||
<div
|
||||
data-testid="step-actions"
|
||||
className="flex justify-end items-center gap-3"
|
||||
>
|
||||
{!isFirstStep && (
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleBack}
|
||||
className="flex-1 px-4 sm:px-6 py-2.5 bg-[050505] text-white border hover:bg-white border-[#242424] hover:text-black"
|
||||
>
|
||||
{t(I18nKey.ONBOARDING$BACK_BUTTON)}
|
||||
</BrandButton>
|
||||
)}
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleNext}
|
||||
isDisabled={!currentSelection}
|
||||
className={cn(
|
||||
"px-4 sm:px-6 py-2.5 bg-white text-black hover:bg-white/90",
|
||||
isFirstStep ? "w-1/2" : "flex-1", // keep "Next" button to the right. Even if "Back" button is not rendered
|
||||
)}
|
||||
>
|
||||
{t(
|
||||
isLastStep
|
||||
? I18nKey.ONBOARDING$FINISH_BUTTON
|
||||
: I18nKey.ONBOARDING$NEXT_BUTTON,
|
||||
)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
|
||||
export default OnboardingForm;
|
||||
@@ -19,7 +19,7 @@ import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
|
||||
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
import { useIsOnIntermediatePage } from "#/hooks/use-is-on-intermediate-page";
|
||||
import { useAutoLogin } from "#/hooks/use-auto-login";
|
||||
import { useAuthCallback } from "#/hooks/use-auth-callback";
|
||||
import { useReoTracking } from "#/hooks/use-reo-tracking";
|
||||
@@ -69,7 +69,7 @@ export default function MainApp() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const isOnTosPage = useIsOnTosPage();
|
||||
const isOnIntermediatePage = useIsOnIntermediatePage();
|
||||
const { data: settings } = useSettings();
|
||||
const { migrateUserConsent } = useMigrateUserConsent();
|
||||
const { t } = useTranslation();
|
||||
@@ -97,25 +97,25 @@ export default function MainApp() {
|
||||
useSyncPostHogConsent();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't change language when on TOS page
|
||||
if (!isOnTosPage && settings?.language) {
|
||||
// Don't change language when on intermediate pages (TOS, profile questions)
|
||||
if (!isOnIntermediatePage && settings?.language) {
|
||||
i18n.changeLanguage(settings.language);
|
||||
}
|
||||
}, [settings?.language, isOnTosPage]);
|
||||
}, [settings?.language, isOnIntermediatePage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't show consent form when on TOS page
|
||||
if (!isOnTosPage) {
|
||||
// Don't show consent form when on intermediate pages
|
||||
if (!isOnIntermediatePage) {
|
||||
const consentFormModalIsOpen =
|
||||
settings?.user_consents_to_analytics === null;
|
||||
|
||||
setConsentFormIsOpen(consentFormModalIsOpen);
|
||||
}
|
||||
}, [settings, isOnTosPage]);
|
||||
}, [settings, isOnIntermediatePage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// Don't migrate user consent when on TOS page
|
||||
if (!isOnTosPage) {
|
||||
// Don't migrate user consent when on intermediate pages
|
||||
if (!isOnIntermediatePage) {
|
||||
// Migrate user consent to the server if it was previously stored in localStorage
|
||||
migrateUserConsent({
|
||||
handleAnalyticsWasPresentInLocalStorage: () => {
|
||||
@@ -123,7 +123,7 @@ export default function MainApp() {
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [isOnTosPage]);
|
||||
}, [isOnIntermediatePage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings?.is_new_user && config.data?.app_mode === "saas") {
|
||||
@@ -178,7 +178,7 @@ export default function MainApp() {
|
||||
isAuthLoading ||
|
||||
(!isAuthed &&
|
||||
!isAuthError &&
|
||||
!isOnTosPage &&
|
||||
!isOnIntermediatePage &&
|
||||
config.data?.app_mode === "saas" &&
|
||||
!loginMethodExists);
|
||||
|
||||
@@ -209,7 +209,7 @@ export default function MainApp() {
|
||||
!isAuthed &&
|
||||
!isAuthError &&
|
||||
!isFetchingAuth &&
|
||||
!isOnTosPage &&
|
||||
!isOnIntermediatePage &&
|
||||
config.data?.app_mode === "saas" &&
|
||||
loginMethodExists;
|
||||
|
||||
|
||||
@@ -17,3 +17,4 @@ export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
|
||||
export const VSCODE_IN_NEW_TAB = () => loadFeatureFlag("VSCODE_IN_NEW_TAB");
|
||||
export const ENABLE_TRAJECTORY_REPLAY = () =>
|
||||
loadFeatureFlag("TRAJECTORY_REPLAY");
|
||||
export const ENABLE_ONBOARDING = () => loadFeatureFlag("ENABLE_ONBOARDING");
|
||||
|
||||
@@ -30,6 +30,10 @@ vi.mock("#/hooks/use-is-on-tos-page", () => ({
|
||||
useIsOnTosPage: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
|
||||
useIsOnIntermediatePage: () => false,
|
||||
}));
|
||||
|
||||
// Import the Zustand mock to enable automatic store resets
|
||||
vi.mock("zustand");
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class DockerSandboxService(SandboxService):
|
||||
ExposedUrl(
|
||||
name=exposed_port.name,
|
||||
url=url,
|
||||
port=host_port,
|
||||
port=exposed_port.container_port,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -193,7 +193,7 @@ class DockerSandboxService(SandboxService):
|
||||
ExposedUrl(
|
||||
name=matching_port.name,
|
||||
url=url,
|
||||
port=host_port,
|
||||
port=matching_port.container_port,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -387,7 +387,7 @@ class DockerSandboxService(SandboxService):
|
||||
for exposed_port in self.exposed_ports:
|
||||
host_port = self._find_unused_port()
|
||||
port_mappings[exposed_port.container_port] = host_port
|
||||
env_vars[exposed_port.name] = str(host_port)
|
||||
env_vars[exposed_port.name] = str(exposed_port.container_port)
|
||||
|
||||
# Prepare labels
|
||||
labels = {
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
# 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/.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.security.options import SecurityAnalyzers
|
||||
@@ -19,45 +20,21 @@ from openhands.utils.llm import get_supported_llm_models
|
||||
app = APIRouter(prefix='/api/options', dependencies=get_dependencies())
|
||||
|
||||
|
||||
@app.get('/models', response_model=list[str])
|
||||
async def get_litellm_models() -> list[str]:
|
||||
"""Get all models supported by LiteLLM.
|
||||
async def get_llm_models_dependency(request: Request) -> list[str]:
|
||||
"""Returns a callable that provides the LLM models implementation.
|
||||
|
||||
This function combines models from litellm and Bedrock, removing any
|
||||
error-prone Bedrock models. In SaaS mode, it uses database-backed
|
||||
verified models for dynamic updates without code deployments.
|
||||
|
||||
To get the models:
|
||||
```sh
|
||||
curl http://localhost:3000/api/litellm-models
|
||||
```
|
||||
|
||||
Returns:
|
||||
list[str]: A sorted list of unique model names.
|
||||
Returns a factory that produces the actual implementation function.
|
||||
Override this in enterprise/saas mode via app.dependency_overrides.
|
||||
"""
|
||||
verified_models = _load_verified_models_from_db()
|
||||
return get_supported_llm_models(config, verified_models)
|
||||
|
||||
return get_supported_llm_models(config, [])
|
||||
|
||||
|
||||
def _load_verified_models_from_db() -> list[str] | None:
|
||||
"""Try to load verified models from the database (SaaS mode only).
|
||||
|
||||
Returns:
|
||||
List of model strings like 'provider/model_name' if available, None otherwise.
|
||||
"""
|
||||
try:
|
||||
from storage.verified_model_store import VerifiedModelStore
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
db_models = VerifiedModelStore.get_enabled_models()
|
||||
return [f'{m.provider}/{m.model_name}' for m in db_models]
|
||||
except Exception:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
logger.exception('Failed to load verified models from database')
|
||||
return None
|
||||
@app.get('/models')
|
||||
async def get_litellm_models(
|
||||
models: list[str] = Depends(get_llm_models_dependency),
|
||||
) -> list[str]:
|
||||
return models
|
||||
|
||||
|
||||
@app.get('/agents', response_model=list[str])
|
||||
|
||||
Reference in New Issue
Block a user