Compare commits

..

11 Commits

Author SHA1 Message Date
openhands d4f7f07d5d test: add comprehensive tests for v1-git-service query parameter changes
- Add tests verifying query parameters are used instead of path segments
- Add tests for preserving slashes in paths (main fix purpose)
- Add tests for session API key headers
- Add tests for V1 to V0 status mapping
- Add tests for getGitChangeDiff endpoint

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-02 02:59:27 +00:00
chuckbutkus a34dc949ce Merge branch 'main' into fix/git-api-use-query-params 2026-03-01 21:39:52 -05:00
Shruti1128 d6b8d80026 Remove unused subscription-related frontend code (#12557) 2026-03-01 21:14:00 +01:00
Hiep Le 1e6a92b454 feat(backend): organizations llm settings api (org project) (#13108) 2026-03-02 00:06:37 +07:00
Hiep Le b4a3e5db2f feat(backend): saas – organizations app settings api (#13022) 2026-03-01 23:26:39 +07:00
openhands 80e4fe1226 fix: use query parameters for V1 git API endpoints to preserve path slashes
Update V1GitService to pass path as a query parameter instead of embedding
it in the URL path segment. This fixes URL path normalization issues with
Traefik/Gateway API where encoded slashes (%2F) in path segments would be
decoded and normalized, causing leading slashes to be lost.

For example, /workspace/project was arriving as workspace/project.

Using query parameters (e.g., ?path=/workspace/project) avoids this issue
as they are passed through without path normalization.

Requires corresponding backend change in software-agent-sdk.

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-01 05:09:30 +00:00
Chris Bagwell f9d553d0bb Pass container port instead of host port to Docker (#12595)
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
2026-02-28 17:45:16 +01:00
Tim O'Farrell f6f6c1ab25 refactor: use SQL filtering and pagination in VerifiedModelStore (#13068)
Co-authored-by: bittoby <brianwhitedev1996@gmail.com>
Co-authored-by: statxc <statxc@user.noreply.github.com>
Co-authored-by: bittoby <bittoby@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 07:37:11 -07:00
Hiep Le c511a89426 feat(frontend): display Bitbucket signup disabled message on login page (#13100) 2026-02-28 19:26:16 +07:00
HeyItsChloe 1f82ff04d9 feat(frontend): SaaS NUE profile questions /Onboarding flow (#13029)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-02-28 13:27:22 +07:00
HeyItsChloe eec17311c7 fix(frontend): bitbucket icon color (#13106)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-02-28 12:12:45 +07:00
57 changed files with 4825 additions and 725 deletions
+11 -3
View File
@@ -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)
+158 -1
View File
@@ -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
+208
View File
@@ -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,
-184
View File
@@ -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
+29 -1
View File
@@ -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)
+47 -3
View File
@@ -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
-39
View File
@@ -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()
)
-187
View File
@@ -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
+3 -1
View File
@@ -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
+96
View File
@@ -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')
+168 -11
View File
@@ -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);
});
});
});
@@ -40,21 +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
*/
/**
* Create a subscription checkout session for subscribing to a plan
* @returns The redirect URL for the subscription checkout session
*/
/**
* Cancel the user's subscription
* @returns The response indicating the result of the cancellation request
*/
}
export default BillingService;
@@ -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>
);
}
@@ -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 = "/";
},
});
};
+3 -3
View File
@@ -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,
});
};
+3 -3
View File
@@ -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,
+3 -3
View File
@@ -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,
},
@@ -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],
);
};
+18
View File
@@ -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,
};
};
+26 -3
View File
@@ -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",
}
+418 -48
View File
@@ -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": "210 people",
"ja": "2〜10人",
"zh-CN": "2-10人",
"zh-TW": "2-10人",
"ko-KR": "2-10명",
"no": "210 personer",
"ar": "2-10 أشخاص",
"de": "210 Personen",
"fr": "210 personnes",
"it": "210 persone",
"pt": "210 pessoas",
"es": "210 personas",
"tr": "210 kişi",
"uk": "210 осіб"
},
"ONBOARDING$ORG_11_50": {
"en": "1150 people",
"ja": "11〜50人",
"zh-CN": "11-50人",
"zh-TW": "11-50人",
"ko-KR": "11-50명",
"no": "1150 personer",
"ar": "11-50 شخصاً",
"de": "1150 Personen",
"fr": "1150 personnes",
"it": "1150 persone",
"pt": "1150 pessoas",
"es": "1150 personas",
"tr": "1150 kişi",
"uk": "1150 осіб"
},
"ONBOARDING$ORG_51_200": {
"en": "51200 people",
"ja": "51〜200人",
"zh-CN": "51-200人",
"zh-TW": "51-200人",
"ko-KR": "51-200명",
"no": "51200 personer",
"ar": "51-200 شخصاً",
"de": "51200 Personen",
"fr": "51200 personnes",
"it": "51200 persone",
"pt": "51200 pessoas",
"es": "51200 personas",
"tr": "51200 kişi",
"uk": "51200 осіб"
},
"ONBOARDING$ORG_200_1000": {
"en": "2001000 people",
"ja": "200〜1000人",
"zh-CN": "200-1000人",
"zh-TW": "200-1000人",
"ko-KR": "200-1000명",
"no": "2001000 personer",
"ar": "200-1000 شخص",
"de": "2001000 Personen",
"fr": "2001000 personnes",
"it": "2001000 persone",
"pt": "2001000 pessoas",
"es": "2001000 personas",
"tr": "2001000 kişi",
"uk": "2001000 осіб"
},
"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": "Завершити"
}
}
+18 -27
View File
@@ -1,31 +1,22 @@
import { delay, http, HttpResponse } from "msw";
// Mock data for credit balance
const MOCK_CREDITS = "100";
export const BILLING_HANDLERS = [
http.get("/api/billing/credits", async () => {
await delay();
return HttpResponse.json({ credits: "100" });
}),
// Factory function to create billing handlers
function createBillingHandlers() {
return [
http.get("/api/billing/credits", async () => {
await delay();
return HttpResponse.json({ credits: MOCK_CREDITS });
}),
http.post("/api/billing/create-checkout-session", async () => {
await delay();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-checkout",
});
}),
http.post("/api/billing/create-checkout-session", async () => {
await delay();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-checkout",
});
}),
http.post("/api/billing/create-customer-setup-session", async () => {
await delay();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-customer-setup",
});
}),
];
}
// Export handler set for testing
export const STRIPE_BILLING_HANDLERS = createBillingHandlers();
http.post("/api/billing/create-customer-setup-session", async () => {
await delay();
return HttpResponse.json({
redirect_url: "https://stripe.com/some-customer-setup",
});
}),
];
+2 -2
View File
@@ -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,
+1
View File
@@ -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"),
+243
View File
@@ -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;
+13 -13
View File
@@ -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;
+1
View File
@@ -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");
+4
View File
@@ -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 = {
+13 -36
View File
@@ -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])