diff --git a/backend/apps/me_model_managment_app.py b/backend/apps/me_model_managment_app.py index 44693b907..210eee75e 100644 --- a/backend/apps/me_model_managment_app.py +++ b/backend/apps/me_model_managment_app.py @@ -1,7 +1,7 @@ import logging from http import HTTPStatus -from fastapi import APIRouter, Query +from fastapi import APIRouter, Query, HTTPException from fastapi.responses import JSONResponse from consts.exceptions import TimeoutException, NotFoundException, MEConnectionException @@ -44,9 +44,9 @@ async def get_me_models( "data": [] }) except Exception as e: - logging.error(f"Failed to get model list: {str(e)}") + logging.error(f"Failed to get me model list: {str(e)}") return JSONResponse(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content={ - "message": f"Failed to get model list: {str(e)}", + "message": f"Failed to get me model list: {str(e)}", "data": [] }) @@ -61,23 +61,16 @@ async def check_me_connectivity(timeout: int = Query(default=2, description="Tim return JSONResponse( status_code=HTTPStatus.OK, content={ - "status": "Connected", - "desc": "Connection successful.", - "connect_status": ModelConnectStatusEnum.AVAILABLE.value + "connectivity": True, + "message": "ModelEngine model connect successfully.", } ) except MEConnectionException as e: - logging.error(f"Request me model connectivity failed: {str(e)}") - return JSONResponse(status_code=HTTPStatus.SERVICE_UNAVAILABLE, content={"status": "Disconnected", - "desc": f"Connection failed.", - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value}) + logging.error(f"ModelEngine model healthcheck failed: {str(e)}") + raise HTTPException(status_code=HTTPStatus.SERVICE_UNAVAILABLE, detail="ModelEngine model connect failed.") except TimeoutException as e: - logging.error(f"Request me model connectivity timeout: {str(e)}") - return JSONResponse(status_code=HTTPStatus.REQUEST_TIMEOUT, content={"status": "Disconnected", - "desc": "Connection timeout.", - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value}) + logging.error(f"ModelEngine model healthcheck timeout: {str(e)}") + raise HTTPException(status_code=HTTPStatus.REQUEST_TIMEOUT, detail="ModelEngine model connect timeout.") except Exception as e: - logging.error(f"Unknown error occurred: {str(e)}.") - return JSONResponse(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, content={"status": "Disconnected", - "desc": f"Unknown error occurred: {str(e)}", - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value}) + logging.error(f"ModelEngine model healthcheck failed with unknown error: {str(e)}.") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="ModelEngine model connect failed.") diff --git a/backend/apps/memory_config_app.py b/backend/apps/memory_config_app.py index c95ec7803..aadc75775 100644 --- a/backend/apps/memory_config_app.py +++ b/backend/apps/memory_config_app.py @@ -1,9 +1,32 @@ +"""Memory configuration and CRUD API endpoints for the app layer. + +This module exposes HTTP endpoints under the `/memory` prefix. It follows the +app-layer responsibilities: +- Parse and validate HTTP inputs +- Delegate business logic to the service layer +- Convert unexpected exceptions to error JSON responses + +Routes: +- GET `/memory/config/load`: Load memory-related configuration for current user +- POST `/memory/config/set`: Set a single configuration entry +- POST `/memory/config/disable_agent`: Add a disabled agent id +- DELETE `/memory/config/disable_agent/{agent_id}`: Remove a disabled agent id +- POST `/memory/config/disable_useragent`: Add a disabled user-agent id +- DELETE `/memory/config/disable_useragent/{agent_id}`: Remove a disabled user-agent id +- POST `/memory/add`: Add memory items (optionally with LLM inference) +- POST `/memory/search`: Semantic search memory items +- GET `/memory/list`: List memory items +- DELETE `/memory/delete/{memory_id}`: Delete a single memory item +- DELETE `/memory/clear`: Clear memory items by scope +""" import asyncio import logging from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Body, Header, Path, Query +from http import HTTPStatus +from fastapi import APIRouter, Body, Header, Path, Query, HTTPException from fastapi.responses import JSONResponse + from nexent.memory.memory_service import ( add_memory as svc_add_memory, clear_memory as svc_clear_memory, @@ -11,12 +34,12 @@ list_memory as svc_list_memory, search_memory as svc_search_memory, ) - from consts.const import ( MEMORY_AGENT_SHARE_KEY, MEMORY_SWITCH_KEY, ) from consts.model import MemoryAgentShareMode +from consts.exceptions import UnauthorizedError from services.memory_config_service import ( add_disabled_agent_id, add_disabled_useragent_id, @@ -34,35 +57,26 @@ router = APIRouter(prefix="/memory") -# --------------------------------------------------------------------------- -# Generic helpers -# --------------------------------------------------------------------------- -def _success(message: str = "success", content: Optional[Any] = None): - return JSONResponse(status_code=200, content={"message": message, "status": "success", "content": content}) - - -def _error(message: str = "error"): - return JSONResponse(status_code=400, content={"message": message, "status": "error"}) - - -# --------------------------------------------------------------------------- -# Helper function -# --------------------------------------------------------------------------- - - # --------------------------------------------------------------------------- # Configuration Endpoints # --------------------------------------------------------------------------- @router.get("/config/load") def load_configs(authorization: Optional[str] = Header(None)): - """Load all memory-related configuration for current user.""" + """Load all memory-related configuration for the current user. + + Args: + authorization: Optional authorization header used to identify the user. + """ try: user_id, _ = get_current_user_id(authorization) configs = get_user_configs(user_id) - return _success(content=configs) + return JSONResponse(status_code=HTTPStatus.OK, content=configs) + except UnauthorizedError as e: + raise HTTPException(status_code=HTTPStatus.UNAUTHORIZED, detail=str(e)) except Exception as e: logger.error("load_configs failed: %s", e) - return _error("Failed to load configuration") + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to load configuration") @router.post("/config/set") @@ -71,7 +85,17 @@ def set_single_config( value: Any = Body(..., embed=True, description="Configuration value"), authorization: Optional[str] = Header(None), ): - """Unified endpoint to set single-value configuration items.""" + """Set a single-value configuration item for the current user. + + Supported keys: + - `MEMORY_SWITCH_KEY`: Toggle memory system on/off (boolean-like values accepted) + - `MEMORY_AGENT_SHARE_KEY`: Set agent share mode (`always`/`ask`/`never`) + + Args: + key: Configuration key to update. + value: New value for the configuration key. + authorization: Optional authorization header used to identify the user. + """ user_id, _ = get_current_user_id(authorization) if key == MEMORY_SWITCH_KEY: @@ -82,12 +106,17 @@ def set_single_config( try: mode = MemoryAgentShareMode(str(value)) except ValueError: - return _error("Invalid value for MEMORY_AGENT_SHARE (expected always/ask/never)") + raise HTTPException(status_code=HTTPStatus.NOT_ACCEPTABLE, + detail="Invalid value for MEMORY_AGENT_SHARE (expected always/ask/never)") ok = set_agent_share(user_id, mode) else: - return _error("Unsupported configuration key") + raise HTTPException(status_code=HTTPStatus.NOT_ACCEPTABLE, + detail="Unsupported configuration key") - return _success() if ok else _error("Failed to update configuration") + if ok: + return JSONResponse(status_code=HTTPStatus.OK, content={"success": True}) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to update configuration") @router.post("/config/disable_agent") @@ -95,9 +124,18 @@ def add_disable_agent( agent_id: str = Body(..., embed=True), authorization: Optional[str] = Header(None), ): + """Add an agent id to the user's disabled agent list. + + Args: + agent_id: Identifier of the agent to disable. + authorization: Optional authorization header used to identify the user. + """ user_id, _ = get_current_user_id(authorization) ok = add_disabled_agent_id(user_id, agent_id) - return _success() if ok else _error("Failed to add disable agent id") + if ok: + return JSONResponse(status_code=HTTPStatus.OK, content={"success": True}) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to add disable agent id") @router.delete("/config/disable_agent/{agent_id}") @@ -105,9 +143,18 @@ def remove_disable_agent( agent_id: str = Path(...), authorization: Optional[str] = Header(None), ): + """Remove an agent id from the user's disabled agent list. + + Args: + agent_id: Identifier of the agent to remove from the disabled list. + authorization: Optional authorization header used to identify the user. + """ user_id, _ = get_current_user_id(authorization) ok = remove_disabled_agent_id(user_id, agent_id) - return _success() if ok else _error("Failed to remove disable agent id") + if ok: + return JSONResponse(status_code=HTTPStatus.OK, content={"success": True}) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to remove disable agent id") @router.post("/config/disable_useragent") @@ -115,9 +162,18 @@ def add_disable_useragent( agent_id: str = Body(..., embed=True), authorization: Optional[str] = Header(None), ): + """Add a user-agent id to the user's disabled user-agent list. + + Args: + agent_id: Identifier of the user-agent to disable. + authorization: Optional authorization header used to identify the user. + """ user_id, _ = get_current_user_id(authorization) ok = add_disabled_useragent_id(user_id, agent_id) - return _success() if ok else _error("Failed to add disable user-agent id") + if ok: + return JSONResponse(status_code=HTTPStatus.OK, content={"success": True}) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to add disable user-agent id") @router.delete("/config/disable_useragent/{agent_id}") @@ -125,9 +181,18 @@ def remove_disable_useragent( agent_id: str = Path(...), authorization: Optional[str] = Header(None), ): + """Remove a user-agent id from the user's disabled user-agent list. + + Args: + agent_id: Identifier of the user-agent to remove from the disabled list. + authorization: Optional authorization header used to identify the user. + """ user_id, _ = get_current_user_id(authorization) ok = remove_disabled_useragent_id(user_id, agent_id) - return _success() if ok else _error("Failed to remove disable user-agent id") + if ok: + return JSONResponse(status_code=HTTPStatus.OK, content={"success": True}) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Failed to remove disable user-agent id") # --------------------------------------------------------------------------- @@ -144,6 +209,15 @@ def add_memory( True, embed=True, description="Whether to run LLM inference during add"), authorization: Optional[str] = Header(None), ): + """Add memory records for the given scope. + + Args: + messages: List of chat messages as dictionaries. + memory_level: Scope for the memory record (tenant/agent/user/user_agent). + agent_id: Optional agent identifier when scope is agent-related. + infer: Whether to run LLM inference during add. + authorization: Optional authorization header used to identify the user. + """ user_id, tenant_id = get_current_user_id(authorization) try: result = asyncio.run(svc_add_memory( @@ -155,10 +229,10 @@ def add_memory( agent_id=agent_id, infer=infer, )) - return _success(content=result) + return JSONResponse(status_code=HTTPStatus.OK, content=result) except Exception as e: logger.error("add_memory error: %s", e, exc_info=True) - return _error(str(e)) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) @router.post("/search") @@ -169,6 +243,15 @@ def search_memory( agent_id: Optional[str] = Body(None, embed=True), authorization: Optional[str] = Header(None), ): + """Search memory semantically for the given scope. + + Args: + query_text: Natural language query to search memory. + memory_level: Scope for search (tenant/agent/user/user_agent). + top_k: Maximum number of results to return. + agent_id: Optional agent identifier when scope is agent-related. + authorization: Optional authorization header used to identify the user. + """ user_id, tenant_id = get_current_user_id(authorization) try: results = asyncio.run(svc_search_memory( @@ -180,10 +263,10 @@ def search_memory( top_k=top_k, agent_id=agent_id, )) - return _success(content=results) + return JSONResponse(status_code=HTTPStatus.OK, content=results) except Exception as e: logger.error("search_memory error: %s", e, exc_info=True) - return _error(str(e)) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) @router.get("/list") @@ -194,6 +277,13 @@ def list_memory( None, description="Filter by agent id if applicable"), authorization: Optional[str] = Header(None), ): + """List memory for the given scope. + + Args: + memory_level: Scope for listing (tenant/agent/user/user_agent). + agent_id: Optional agent filter when scope is agent-related. + authorization: Optional authorization header used to identify the user. + """ user_id, tenant_id = get_current_user_id(authorization) try: payload = asyncio.run(svc_list_memory( @@ -203,10 +293,10 @@ def list_memory( user_id=user_id, agent_id=agent_id, )) - return _success(content=payload) + return JSONResponse(status_code=HTTPStatus.OK, content=payload) except Exception as e: logger.error("list_memory error: %s", e, exc_info=True) - return _error(str(e)) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) @router.delete("/delete/{memory_id}") @@ -214,14 +304,20 @@ def delete_memory( memory_id: str = Path(..., description="ID of memory to delete"), authorization: Optional[str] = Header(None), ): + """Delete a specific memory record by id. + + Args: + memory_id: Identifier of the memory record to delete. + authorization: Optional authorization header used to identify the user. + """ _user_id, tenant_id = get_current_user_id(authorization) try: result = asyncio.run(svc_delete_memory( memory_id=memory_id, memory_config=build_memory_config(tenant_id))) - return _success(content=result) + return JSONResponse(status_code=HTTPStatus.OK, content=result) except Exception as e: logger.error("delete_memory error: %s", e, exc_info=True) - return _error(str(e)) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) @router.delete("/clear") @@ -232,6 +328,13 @@ def clear_memory( None, description="Filter by agent id if applicable"), authorization: Optional[str] = Header(None), ): + """Clear memory records for the given scope. + + Args: + memory_level: Scope for clearing (tenant/agent/user/user_agent). + agent_id: Optional agent filter when scope is agent-related. + authorization: Optional authorization header used to identify the user. + """ user_id, tenant_id = get_current_user_id(authorization) try: result = asyncio.run(svc_clear_memory( @@ -241,7 +344,7 @@ def clear_memory( user_id=user_id, agent_id=agent_id, )) - return _success(content=result) + return JSONResponse(status_code=HTTPStatus.OK, content=result) except Exception as e: logger.error("clear_memory error: %s", e, exc_info=True) - return _error(str(e)) + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) diff --git a/backend/apps/model_managment_app.py b/backend/apps/model_managment_app.py index 40ecba18e..fe9abfbd4 100644 --- a/backend/apps/model_managment_app.py +++ b/backend/apps/model_managment_app.py @@ -1,392 +1,310 @@ +"""FastAPI App layer for model management endpoints. + +This module exposes HTTP endpoints under the prefix "/model". It follows the App +layer contract: +- Parse and validate inputs using Pydantic models from `consts.model` and FastAPI parameters. +- Delegate business logic to services and database layer; do not implement core logic here. +- Map domain/service exceptions to HTTP where necessary; avoid leaking internals. +- Return structured responses consistent with existing patterns for backward compatibility. + +Authorization: The bearer token is retrieved via the `authorization` header and +parsed with `utils.auth_utils.get_current_user_id`, then propagated as `user_id` +and `tenant_id` to services/database helpers. +""" + import logging from consts.model import ( BatchCreateModelsRequest, - ModelConnectStatusEnum, ModelRequest, - ModelResponse, ProviderModelRequest, ) -from consts.provider import ProviderEnum, SILICON_BASE_URL -from database.model_management_db import ( - create_model_record, - delete_model_record, - get_model_by_display_name, - get_model_records, - get_models_by_tenant_factory_type, - update_model_record, -) + from fastapi import APIRouter, Header, Query, HTTPException from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder from http import HTTPStatus from typing import List, Optional -from services.model_health_service import check_model_connectivity, embedding_dimension_check -from services.model_provider_service import prepare_model_dict, merge_existing_model_tokens, get_provider_models +from services.model_health_service import ( + check_model_connectivity, + verify_model_config_connectivity, +) +from services.model_management_service import ( + create_model_for_tenant, + create_provider_models_for_tenant, + batch_create_models_for_tenant, + list_provider_models_for_tenant, + update_single_model_for_tenant, + batch_update_models_for_tenant, + delete_model_for_tenant, + list_models_for_tenant, +) from utils.auth_utils import get_current_user_id -from utils.model_name_utils import add_repo_to_name, split_display_name, split_repo_name, sort_models_by_id + router = APIRouter(prefix="/model") logger = logging.getLogger("model_management_app") -@router.post("/create", response_model=ModelResponse) +@router.post("/create") async def create_model(request: ModelRequest, authorization: Optional[str] = Header(None)): - try: - user_id, tenant_id = get_current_user_id(authorization) - logger.info( - f"Start to create model, user_id: {user_id}, tenant_id: {tenant_id}") - model_data = request.model_dump() - # Replace localhost with host.docker.internal for local llm - model_base_url = model_data.get("base_url", "") - if "localhost" in model_base_url or "127.0.0.1" in model_base_url: - model_data["base_url"] = ( - model_base_url.replace("localhost", "host.docker.internal") - .replace("127.0.0.1", "host.docker.internal") - ) - # Split model_name - model_repo, model_name = split_repo_name(model_data["model_name"]) - # Ensure model_repo is empty string instead of null - model_data["model_repo"] = model_repo if model_repo else "" - model_data["model_name"] = model_name - - if not model_data.get("display_name"): - model_data["display_name"] = split_display_name( - model_data["model_name"]) - - # Use NOT_DETECTED status as default - model_data["connect_status"] = model_data.get( - "connect_status") or ModelConnectStatusEnum.NOT_DETECTED.value - - # Check if display_name conflicts - if model_data.get("display_name"): - existing_model_by_display = get_model_by_display_name( - model_data["display_name"], tenant_id) - if existing_model_by_display: - return ModelResponse( - code=409, - message=f"Name {model_data['display_name']} is already in use, please choose another display name", - data=None - ) - - if model_data.get("model_type") == "embedding" or model_data.get("model_type") == "multi_embedding": - model_data["max_tokens"] = await embedding_dimension_check(model_data) - - # Check if this is a multimodal embedding model - is_multimodal = model_data.get("model_type") == "multi_embedding" - - # If it's multi_embedding type, create both embedding and multi_embedding records - if is_multimodal: - # Create the multi_embedding record - create_model_record(model_data, user_id, tenant_id) - - # Create the embedding record with the same data but different model_type - embedding_data = model_data.copy() - embedding_data["model_type"] = "embedding" - create_model_record(embedding_data, user_id, tenant_id) - - return ModelResponse( - code=200, - message=f"Multimodal embedding model {add_repo_to_name(model_repo, model_name)} created successfully", - data=None - ) - else: - # For non-multimodal models, just create one record - create_model_record(model_data, user_id, tenant_id) - return ModelResponse( - code=200, - message=f"Model {add_repo_to_name(model_repo, model_name)} created successfully", - data=None - ) - except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to create model: {str(e)}", - data=None - ) + """Create a single model record for the current tenant. + Responsibilities (App layer): + - Validate `ModelRequest` payload. + - Normalize request fields (e.g., replace localhost in `base_url`). + - Delegate embedding dimension checks and record creation to services/db. + - Ensure display name uniqueness at the app boundary; map conflicts accordingly. -@router.post("/create_provider", response_model=ModelResponse) -async def create_provider_model(request: ProviderModelRequest, authorization: Optional[str] = Header(None)): + Args: + request: Model configuration payload. + authorization: Bearer token header used to derive `user_id` and `tenant_id`. + """ try: user_id, tenant_id = get_current_user_id(authorization) model_data = request.model_dump() + logger.debug( + f"Start to create model, user_id: {user_id}, tenant_id: {tenant_id}") + await create_model_for_tenant(user_id, tenant_id, model_data) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Model created successfully" + }) + except ValueError as e: + logging.error(f"Failed to create model: {str(e)}") + raise HTTPException(status_code=HTTPStatus.CONFLICT, + detail="Failed to create model: name conflict") + except Exception as e: + logging.error(f"Failed to create model: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Failed to create model") - # Get provider model list - model_list = await get_provider_models(model_data) - # Merge existing model's max_tokens attribute - model_list = merge_existing_model_tokens( - model_list, tenant_id, request.provider, request.model_type) +@router.post("/provider/create") +async def create_provider_model(request: ProviderModelRequest, authorization: Optional[str] = Header(None)): + """Create or refresh provider models for the current tenant in memory only. - # Sort model list by ID - model_list = sort_models_by_id(model_list) + This endpoint fetches models from the specified provider and merges existing + attributes (such as `max_tokens`). It does not persist new records; it + returns the prepared model list for client consumption. - return ModelResponse( - code=200, - message=f"Provider model {model_data['provider']} created successfully", - data=model_list - ) + Args: + request: Provider and model type information. + authorization: Bearer token header used to derive identity context. + """ + try: + provider_model_config = request.model_dump() + _, tenant_id = get_current_user_id(authorization) + model_list = await create_provider_models_for_tenant(tenant_id, provider_model_config) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Provider model created successfully", + "data": model_list + }) except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to create provider model: {str(e)}", - data=None - ) + logging.error(f"Failed to create provider model: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to create provider model") -@router.post("/batch_create_models", response_model=ModelResponse) +@router.post("/provider/batch_create") async def batch_create_models(request: BatchCreateModelsRequest, authorization: Optional[str] = Header(None)): + """Synchronize provider models for a tenant by creating/updating/deleting records. + + The request includes the authoritative list of models for a provider/type. + Existing models not present in the incoming list are deleted (soft delete), + and missing ones are created. Existing models may be updated (e.g., `max_tokens`). + + Args: + request: Batch payload with provider, type, models, and optional API key. + authorization: Bearer token header used to derive identity context. + + """ try: user_id, tenant_id = get_current_user_id(authorization) - model_list = request.models - model_api_key = request.api_key - if request.provider == ProviderEnum.SILICON.value: - model_url = SILICON_BASE_URL - else: - model_url = "" - existing_model_list = get_models_by_tenant_factory_type( - tenant_id, request.provider, request.type) - model_list_ids = {model.get('id') - for model in model_list} if model_list else set() - # delete existing model - for model in existing_model_list: - model_full_name = model["model_repo"] + "/" + model["model_name"] - if model_full_name not in model_list_ids: - delete_model_record(model["model_id"], user_id, tenant_id) - # create new model - for model in model_list: - model_repo, model_name = split_repo_name(model["id"]) - model_display_name = split_display_name(model["id"]) - if model_name: - existing_model_by_display = get_model_by_display_name( - request.provider + "/" + model_display_name, tenant_id) - if existing_model_by_display: - # Check if max_tokens has changed - existing_max_tokens = existing_model_by_display["max_tokens"] - new_max_tokens = model["max_tokens"] - if existing_max_tokens != new_max_tokens: - update_model_record(existing_model_by_display["model_id"], { - "max_tokens": new_max_tokens}, user_id) - continue - - model_dict = await prepare_model_dict( - provider=request.provider, - model=model, - model_url=model_url, - model_api_key=model_api_key - ) - create_model_record(model_dict, user_id, tenant_id) - - return ModelResponse( - code=200, - message=f"Batch create models successfully", - data=None - ) + batch_model_config = request.model_dump() + await batch_create_models_for_tenant(user_id, tenant_id, batch_model_config) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Batch create models successfully" + }) except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to batch create models: {str(e)}", - data=None - ) + logging.error(f"Failed to batch create models: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to batch create models") -@router.post("/provider/list", response_model=ModelResponse) +@router.post("/provider/list") async def get_provider_list(request: ProviderModelRequest, authorization: Optional[str] = Header(None)): + """List persisted models for a provider and type for the current tenant. + + Args: + request: Provider and model type to filter. + authorization: Bearer token header used to derive identity context. + + """ try: - user_id, tenant_id = get_current_user_id(authorization) - provider = request.provider - model_type = request.model_type - model_list = get_models_by_tenant_factory_type( - tenant_id, provider, model_type) - for model in model_list: - model["id"] = model["model_repo"] + "/" + model["model_name"] - return ModelResponse( - code=200, - message=f"Provider model {provider} created successfully", - data=model_list + _, tenant_id = get_current_user_id(authorization) + model_list = await list_provider_models_for_tenant( + tenant_id, request.provider, request.model_type ) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully retrieved provider list", + "data": jsonable_encoder(model_list) + }) except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to get provider list: {str(e)}", - data=None - ) + logging.error(f"Failed to get provider list: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to get provider list") -@router.post("/update_single_model", response_model=ModelResponse) +@router.post("/update") async def update_single_model(request: dict, authorization: Optional[str] = Header(None)): + """Update a single model by its `model_id`. + + Performs a uniqueness check on `display_name` within the tenant and updates + the record if valid. + + Args: + request: Arbitrary model fields with required `model_id`. + authorization: Bearer token header used to derive identity context. + + Raises: + HTTPException: 409 if `display_name` conflicts, 500 for unexpected errors. + """ try: user_id, tenant_id = get_current_user_id(authorization) - model_data = request - existing_model_by_display = get_model_by_display_name( - model_data["display_name"], tenant_id) - if existing_model_by_display and existing_model_by_display["model_id"] != model_data["model_id"]: - raise HTTPException( - status_code=int(HTTPStatus.CONFLICT), - detail=f"Name {model_data['display_name']} is already in use, please choose another display name" - ) - # model_data["model_repo"], model_data["model_name"] = split_repo_name(model_data["model_name"]) - update_model_record(model_data["model_id"], model_data, user_id) - return JSONResponse( - status_code=int(HTTPStatus.OK), - content={ - "code": int(HTTPStatus.OK), - "message": f"Model {model_data['display_name']} updated successfully", - "data": None - } - ) + await update_single_model_for_tenant(user_id, tenant_id, request) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Model updated successfully" + }) + except ValueError as e: + logging.error(f"Failed to update model: {str(e)}") + raise HTTPException(status_code=HTTPStatus.CONFLICT, + detail="Failed to update model: name conflict") except Exception as e: - raise HTTPException( - status_code=int(HTTPStatus.INTERNAL_SERVER_ERROR), - detail=f"Failed to update model: {str(e)}" - ) + logging.error(f"Failed to update model: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to update model") -@router.post("/batch_update_models", response_model=ModelResponse) +@router.post("/batch_update") async def batch_update_models(request: List[dict], authorization: Optional[str] = Header(None)): + """Batch update multiple models for the current tenant. + + Args: + request: List of partial model payloads with `model_id` fields. + authorization: Bearer token header used to derive identity context. + """ try: user_id, tenant_id = get_current_user_id(authorization) - model_list = request - for model in model_list: - update_model_record(model["model_id"], model, user_id) - return ModelResponse( - code=200, - message=f"Batch update models successfully", - data=None - ) + await batch_update_models_for_tenant(user_id, tenant_id, request) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Batch update models successfully" + }) except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to batch update models: {str(e)}", - data=None - ) + logging.error(f"Failed to batch update models: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to batch update models") -@router.post("/delete", response_model=ModelResponse) +@router.post("/delete") async def delete_model(display_name: str = Query(..., embed=True), authorization: Optional[str] = Header(None)): - """ - Soft delete the specified model by display_name - If the model is an embedding or multi_embedding type, both types will be deleted + """Soft delete model(s) by `display_name` for the current tenant. + + Behavior: + - If the model type is `embedding` or `multi_embedding`, both records with the + same `display_name` will be deleted to keep them in sync. Args: - display_name: Display name of the model to delete (unique key) - authorization: Authorization header + display_name: Display name of the model to delete (unique key). + authorization: Bearer token header used to derive identity context. """ try: user_id, tenant_id = get_current_user_id(authorization) logger.info( f"Start to delete model, user_id: {user_id}, tenant_id: {tenant_id}") - # Find model by display_name - model = get_model_by_display_name(display_name, tenant_id) - if not model: - return ModelResponse( - code=404, - message=f"Model not found: {display_name}", - data=None - ) - # Support mutual deletion of embedding/multi_embedding - deleted_types = [] - if model["model_type"] in ["embedding", "multi_embedding"]: - # Find all embedding/multi_embedding models with the same display_name - for t in ["embedding", "multi_embedding"]: - m = get_model_by_display_name(display_name, tenant_id) - if m and m["model_type"] == t: - delete_model_record(m["model_id"], user_id, tenant_id) - deleted_types.append(t) - else: - delete_model_record(model["model_id"], user_id, tenant_id) - deleted_types.append(model.get("model_type", "unknown")) - - return ModelResponse( - code=200, - message=f"Successfully deleted model(s) in types: {', '.join(deleted_types)}", - data={"display_name": display_name} - ) + model_name = await delete_model_for_tenant(user_id, tenant_id, display_name) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Model deleted successfully", + "data": model_name + }) + except LookupError as e: + logging.error(f"Failed to delete model: {str(e)}") + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, + detail="Failed to delete model: model not found") except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to delete model: {str(e)}", - data=None - ) + logging.error(f"Failed to delete model: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to delete model") -@router.get("/list", response_model=ModelResponse) +@router.get("/list") async def get_model_list(authorization: Optional[str] = Header(None)): - """ - Get detailed information for all models + """Get detailed information for all models for the current tenant. + + Returns each model enriched with repo-qualified `model_name` and a normalized + `connect_status` value. """ try: user_id, tenant_id = get_current_user_id(authorization) - logger.info( + logger.debug( f"Start to list models, user_id: {user_id}, tenant_id: {tenant_id}") - records = get_model_records(None, tenant_id) - - result = [] - # Use add_repo_to_name method for each record to add repo prefix to model_name - for record in records: - record["model_name"] = add_repo_to_name( - model_repo=record["model_repo"], - model_name=record["model_name"] - ) - # Handle connect_status, use default value "Not Detected" if empty - record["connect_status"] = ModelConnectStatusEnum.get_value( - record.get("connect_status")) - result.append(record) - - return ModelResponse( - code=200, - message="Successfully retrieved model list", - data=result - ) + model_list = await list_models_for_tenant(tenant_id) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully retrieved model list", + "data": jsonable_encoder(model_list) + }) except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to retrieve model list: {str(e)}", - data=[] - ) + logging.error(f"Failed to list models: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to retrieve model list") -@router.post("/healthcheck", response_model=ModelResponse) -async def check_model_healthcheck( +@router.post("/healthcheck") +async def check_model_health( display_name: str = Query(..., description="Display name to check"), authorization: Optional[str] = Header(None) ): - """ - Check and update model connectivity (health check), and return the latest status. - Args: - display_name: display_name of the model to check - authorization: Authorization header - Returns: - ModelResponse: contains connectivity and latest status - """ - return await check_model_connectivity(display_name, authorization) + """Check and update model connectivity, returning the latest status. - -@router.post("/verify_config", response_model=ModelResponse) -async def verify_model_config(request: ModelRequest): - """ - Verify the connectivity of the model configuration, do not save to database Args: - request: model configuration information - Returns: - ModelResponse: contains connectivity test result + display_name: Display name of the model to check. + authorization: Bearer token header used to derive identity context. """ try: - from services.model_health_service import verify_model_config_connectivity + _, tenant_id = get_current_user_id(authorization) + result = await check_model_connectivity(display_name, tenant_id) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully checked model connectivity", + "data": result + }) + except LookupError as e: + logging.error(f"Failed to check model connectivity: {str(e)}") + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, + detail="Model configuration not found") + except ValueError as e: + logging.error(f"Invalid model configuration: {str(e)}") + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, + detail="Invalid model configuration") + except Exception as e: + logging.error(f"Failed to check model connectivity: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to check model connectivity") - model_data = request.model_dump() - # Call the verification service directly, do not split model_name - result = await verify_model_config_connectivity(model_data) +@router.post("/temporary_healthcheck") +async def check_temporary_model_health(request: ModelRequest): + """Verify connectivity for the provided model configuration without persisting it. - return result - except Exception as e: - return ModelResponse( - code=500, - message=f"Failed to verify model configuration: {str(e)}", - data={ - "connectivity": False, - "message": f"Verification failed: {str(e)}", - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value - } + Args: + request: Model configuration to verify. + """ + try: + result = await verify_model_config_connectivity(request.model_dump()) + return JSONResponse(status_code=HTTPStatus.OK, content={ + "message": "Successfully verified model connectivity", + "data": result + }, ) + except Exception as e: + logging.error(f"Failed to verify model connectivity: {str(e)}") + raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Failed to verify model connectivity") diff --git a/backend/apps/user_management_app.py b/backend/apps/user_management_app.py index a0c536b72..92609f6ec 100644 --- a/backend/apps/user_management_app.py +++ b/backend/apps/user_management_app.py @@ -5,7 +5,7 @@ from fastapi.responses import JSONResponse from http import HTTPStatus -from gotrue.errors import AuthApiError, AuthWeakPasswordError +from supabase_auth.errors import AuthApiError, AuthWeakPasswordError from consts.model import UserSignInRequest, UserSignUpRequest from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException diff --git a/backend/consts/const.py b/backend/consts/const.py index 1d4b1b3a7..75d39463d 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -158,6 +158,9 @@ # Invite code INVITE_CODE = os.getenv("INVITE_CODE") +# Debug JWT expiration time (seconds), not set or 0 means not effective +DEBUG_JWT_EXPIRE_SECONDS = int(os.getenv('DEBUG_JWT_EXPIRE_SECONDS', '0') or 0) + MODEL_CONFIG_MAPPING = { "llm": "LLM_ID", "llmSecondary": "LLM_SECONDARY_ID", diff --git a/backend/database/db_models.py b/backend/database/db_models.py index aefe55328..7cd328263 100644 --- a/backend/database/db_models.py +++ b/backend/database/db_models.py @@ -27,14 +27,6 @@ class ConversationRecord(TableBase): conversation_id = Column(Integer, Sequence( "conversation_record_t_conversation_id_seq", schema=SCHEMA), primary_key=True, nullable=False) conversation_title = Column(String(100), doc="Conversation title") - delete_flag = Column(String( - 1), default="N", doc="After the user deletes it on the frontend, the deletion flag will be set to \"Y\" for soft deletion. Optional values: Y/N") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update date, audit field") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - updated_by = Column(String(100), doc="ID of the last updater, audit field") - created_by = Column(String(100), doc="ID of the creator, audit field") class ConversationMessage(TableBase): @@ -57,14 +49,6 @@ class ConversationMessage(TableBase): String, doc="Images or documents uploaded by the user on the chat page, stored as a list") opinion_flag = Column(String( 1), doc="User evaluation of the conversation. Enumeration value \"Y\" represents a positive review, \"N\" represents a negative review") - delete_flag = Column(String( - 1), default="N", doc="After the user deletes it on the frontend, the deletion flag will be set to \"Y\" for soft deletion. Optional values: Y/N") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update date, audit field") - created_by = Column(String(100), doc="ID of the creator, audit field") - updated_by = Column(String(100), doc="ID of the last updater, audit field") class ConversationMessageUnit(TableBase): @@ -85,14 +69,6 @@ class ConversationMessageUnit(TableBase): unit_type = Column(String(100), doc="Type of the smallest answer unit") unit_content = Column( String, doc="Complete content of the smallest reply unit") - delete_flag = Column(String( - 1), default="N", doc="After the user deletes it on the frontend, the deletion flag will be set to \"Y\" for soft deletion. Optional values: Y/N") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update date, audit field") - updated_by = Column(String(100), doc="ID of the last updater, audit field") - created_by = Column(String(100), doc="ID of the creator, audit field") class ConversationSourceImage(TableBase): @@ -115,14 +91,6 @@ class ConversationSourceImage(TableBase): Integer, doc="[Reserved] Citation serial number for precise traceability") search_type = Column(String( 100), doc="[Reserved] Search source type, used to distinguish the retrieval tool from which the record originates. Optional values: web/local") - delete_flag = Column(String( - 1), default="N", doc="After the user deletes it on the frontend, the deletion flag will be set to \"Y\" for soft deletion. Optional values: Y/N") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update date, audit field") - created_by = Column(String(100), doc="ID of the creator, audit field") - updated_by = Column(String(100), doc="ID of the last updater, audit field") class ConversationSourceSearch(TableBase): @@ -159,14 +127,6 @@ class ConversationSourceSearch(TableBase): 100), doc="Search source type, specifically describing the retrieval tool used for this search record. Optional values: web_search/knowledge_base_search") tool_sign = Column(String( 30), doc="Simple tool identifier used to distinguish the index source in the summary text output by the large model") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update date, audit field") - delete_flag = Column(String( - 1), default="N", doc="After the user deletes it on the frontend, the deletion flag will be set to \"Y\" for soft deletion. Optional values: Y/N") - updated_by = Column(String(100), doc="ID of the last updater, audit field") - created_by = Column(String(100), doc="ID of the creator, audit field") class ModelRecord(TableBase): @@ -196,14 +156,6 @@ class ModelRecord(TableBase): connect_status = Column(String( 100), doc="Model connectivity status of the latest detection. Optional values: Detecting, Available, Unavailable") tenant_id = Column(String(100), doc="Tenant ID for filtering") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - delete_flag = Column(String( - 1), default="N", doc="After the user deletes it on the frontend, the deletion flag will be set to \"Y\" for soft deletion. Optional values: Y/N") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update date, audit field") - updated_by = Column(String(100), doc="ID of the last updater, audit field") - created_by = Column(String(100), doc="ID of the creator, audit field") class ToolInfo(TableBase): @@ -284,14 +236,6 @@ class KnowledgeRecord(TableBase): knowledge_sources = Column(String(300), doc="Knowledge base sources") embedding_model_name = Column(String(200), doc="Embedding model name, used to record the embedding model used by the knowledge base") tenant_id = Column(String(100), doc="Tenant ID") - delete_flag = Column(String( - 1), default="N", doc="Knowledge base status. Currently defaults to 1, if knowledge base status is 0, then this knowledge base is unavailable") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update date, audit field") - updated_by = Column(String(100), doc="ID of the last updater, audit field") - created_by = Column(String(100), doc="ID of the creator, audit field") class TenantConfig(TableBase): @@ -309,14 +253,6 @@ class TenantConfig(TableBase): 100), doc=" the data type of config_value, optional values: single/multi", default="single") config_key = Column(String(100), doc="the key of the config") config_value = Column(String(10000), doc="the value of the config") - create_time = Column(TIMESTAMP(timezone=False), - server_default=func.now(), doc="Creation time") - update_time = Column(TIMESTAMP(timezone=False), - server_default=func.now(), doc="Update time") - created_by = Column(String(100), doc="Creator") - updated_by = Column(String(100), doc="Updater") - delete_flag = Column(String(1), default="N", - doc="Whether it is deleted. Optional values: Y/N") class MemoryUserConfig(TableBase): @@ -334,14 +270,6 @@ class MemoryUserConfig(TableBase): 100), doc=" the data type of config_value, optional values: single/multi", default="single") config_key = Column(String(100), doc="the key of the config") config_value = Column(String(10000), doc="the value of the config") - create_time = Column(TIMESTAMP(timezone=False), - server_default=func.now(), doc="Creation time") - update_time = Column(TIMESTAMP(timezone=False), - server_default=func.now(), doc="Update time") - created_by = Column(String(100), doc="Creator") - updated_by = Column(String(100), doc="Updater") - delete_flag = Column(String(1), default="N", - doc="Whether it is deleted. Optional values: Y/N") class McpRecord(TableBase): @@ -359,14 +287,6 @@ class McpRecord(TableBase): mcp_server = Column(String(500), doc="MCP server address") status = Column(Boolean, default=None, doc="MCP server connection status, True=connected, False=disconnected, None=unknown") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update time, audit field") - created_by = Column(String(100), doc="Creator ID, audit field") - updated_by = Column(String(100), doc="Last updater ID, audit field") - delete_flag = Column(String( - 1), default="N", doc="When deleted by user frontend, delete flag will be set to true, achieving soft delete effect. Optional values Y/N") class UserTenant(TableBase): @@ -380,14 +300,6 @@ class UserTenant(TableBase): primary_key=True, nullable=False, doc="User tenant relationship ID, unique primary key") user_id = Column(String(100), nullable=False, doc="User ID") tenant_id = Column(String(100), nullable=False, doc="Tenant ID") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update time, audit field") - created_by = Column(String(100), doc="Creator ID, audit field") - updated_by = Column(String(100), doc="Last updater ID, audit field") - delete_flag = Column(String( - 1), default="N", doc="When deleted by user frontend, delete flag will be set to true, achieving soft delete effect. Optional values Y/N") class AgentRelation(TableBase): @@ -402,14 +314,6 @@ class AgentRelation(TableBase): selected_agent_id = Column(Integer, doc="Selected agent ID") parent_agent_id = Column(Integer, doc="Parent agent ID") tenant_id = Column(String(100), doc="Tenant ID") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update time, audit field") - created_by = Column(String(100), doc="Creator ID, audit field") - updated_by = Column(String(100), doc="Last updater ID, audit field") - delete_flag = Column(String( - 1), default="N", doc="Delete flag, set to Y for soft delete, optional values Y/N") class PartnerMappingId(TableBase): @@ -429,11 +333,3 @@ class PartnerMappingId(TableBase): 30), doc="Type of the external - internal mapping, value set: CONVERSATION") tenant_id = Column(String(100), doc="Tenant ID") user_id = Column(String(100), doc="User ID") - create_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Creation time, audit field") - update_time = Column(TIMESTAMP( - timezone=False), server_default=func.now(), doc="Update time, audit field") - created_by = Column(String(100), doc="Creator ID, audit field") - updated_by = Column(String(100), doc="Last updater ID, audit field") - delete_flag = Column(String( - 1), default="N", doc="Delete flag, set to Y for soft delete, optional values Y/N") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3256ad9c6..0a003c2be 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "psycopg2-binary==2.9.10", "PyJWT>=2.8.0", "sqlalchemy~=2.0.37", - "supabase==2.15.0", + "supabase>=2.18.1", "websocket-client>=1.8.0", "pyyaml>=6.0.2", "redis>=5.0.0", diff --git a/backend/services/me_model_management_service.py b/backend/services/me_model_management_service.py index b4f5e95f8..587abbdf7 100644 --- a/backend/services/me_model_management_service.py +++ b/backend/services/me_model_management_service.py @@ -22,7 +22,7 @@ async def get_me_models_impl(timeout: int = 2, type: str = "") -> List: } async with aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=timeout), - connector=aiohttp.TCPConnector(verify_ssl=False) + connector=aiohttp.TCPConnector(ssl=False) ) as session: async with session.get( f"{MODEL_ENGINE_HOST}/open/router/v1/models", diff --git a/backend/services/model_health_service.py b/backend/services/model_health_service.py index 4f2ad09ed..d95be4652 100644 --- a/backend/services/model_health_service.py +++ b/backend/services/model_health_service.py @@ -1,30 +1,28 @@ import asyncio import logging +import aiohttp from http import HTTPStatus -from typing import Optional -import aiohttp -from fastapi import Header +from nexent.core import MessageObserver +from nexent.core.models import OpenAIModel, OpenAIVLModel +from nexent.core.models.embedding_model import JinaEmbedding, OpenAICompatibleEmbedding from apps.voice_app import VoiceService from consts.const import MODEL_ENGINE_APIKEY, MODEL_ENGINE_HOST from consts.exceptions import MEConnectionException, TimeoutException -from consts.model import ModelConnectStatusEnum, ModelResponse +from consts.model import ModelConnectStatusEnum from database.model_management_db import get_model_by_display_name, update_model_record -from nexent.core import MessageObserver -from nexent.core.models import OpenAIModel, OpenAIVLModel -from nexent.core.models.embedding_model import JinaEmbedding, OpenAICompatibleEmbedding -from utils.auth_utils import get_current_user_id from utils.config_utils import get_model_name_from_config logger = logging.getLogger("model_health_service") async def _embedding_dimension_check( - model_name: str, - model_type: str, - model_base_url: str, - model_api_key: str): + model_name: str, + model_type: str, + model_base_url: str, + model_api_key: str +): # Test connectivity based on different model types if model_type == "embedding": embedding = await OpenAICompatibleEmbedding( @@ -35,6 +33,9 @@ async def _embedding_dimension_check( ).dimension_check() if len(embedding) > 0: return len(embedding[0]) + logging.warning( + f"Embedding dimension check for {model_name} gets empty response") + return 0 elif model_type == "multi_embedding": embedding = await JinaEmbedding( model_name=model_name, @@ -44,16 +45,19 @@ async def _embedding_dimension_check( ).dimension_check() if len(embedding) > 0: return len(embedding[0]) - - return 0 + logging.warning( + f"Embedding dimension check for {model_name} gets empty response") + return 0 + else: + raise ValueError(f"Unsupported model type: {model_type}") async def _perform_connectivity_check( - model_name: str, - model_type: str, - model_base_url: str, - model_api_key: str, - embedding_dim: int = 1024 + model_name: str, + model_type: str, + model_base_url: str, + model_api_key: str, + embedding_dim: int ) -> bool: """ Perform specific model connectivity check @@ -113,14 +117,12 @@ async def _perform_connectivity_check( return connectivity -async def check_model_connectivity(display_name: str, authorization: Optional[str] = Header(None)): +async def check_model_connectivity(display_name: str, tenant_id: str) -> dict: try: - # Query the database using display_name - user_id, tenant_id = get_current_user_id(authorization) + # Query the database using display_name and tenant context from app layer model = get_model_by_display_name(display_name, tenant_id=tenant_id) if not model: - return ModelResponse(code=404, message=f"Model configuration not found for {display_name}", - data={"connectivity": False, "connect_status": "Not Found"}) + raise LookupError(f"Model configuration not found for {display_name}") # Still use repo/name concatenation for model instantiation repo, name = model.get("model_repo", ""), model.get("model_name", "") @@ -141,32 +143,26 @@ async def check_model_connectivity(display_name: str, authorization: Optional[st model_name, model_type, model_base_url, model_api_key ) except Exception as e: - update_data = { - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value} + update_data = {"connect_status": ModelConnectStatusEnum.UNAVAILABLE.value} logger.error(f"Error checking model connectivity: {str(e)}") update_model_record(model["model_id"], update_data) - return ModelResponse(code=400, message=str(e), - data={"connectivity": False, "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value}) + raise e if connectivity: - logger.info( - f"CONNECTED: {model_name}; Base URL: {model.get('base_url')}; API Key: {model.get('api_key')}") + logger.info(f"CONNECTED: {model_name}; Base URL: {model.get('base_url')}; API Key: {model.get('api_key')}") else: - logger.warning( - f"UNCONNECTED: {model_name}; Base URL: {model.get('base_url')}; API Key: {model.get('api_key')}") + logger.warning(f"UNCONNECTED: {model_name}; Base URL: {model.get('base_url')}; API Key: {model.get('api_key')}") connect_status = ModelConnectStatusEnum.AVAILABLE.value if connectivity else ModelConnectStatusEnum.UNAVAILABLE.value update_data = {"connect_status": connect_status} update_model_record(model["model_id"], update_data) - return ModelResponse(code=200, message=f"Model {display_name} connectivity {'successful' if connectivity else 'failed'}", - data={"connectivity": connectivity, "connect_status": connect_status}) + return {"connectivity": connectivity, "connect_status": connect_status} except Exception as e: logger.error(f"Error checking model connectivity: {str(e)}") if 'model' in locals() and model: - update_data = { - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value} + update_data = {"connect_status": ModelConnectStatusEnum.UNAVAILABLE.value} update_model_record(model["model_id"], update_data) - return ModelResponse(code=500, message=f"Connectivity test error: {str(e)}", - data={"connectivity": False, "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value}) + # Propagate for app layer to translate into HTTP + raise e async def check_me_connectivity_impl(timeout: int): @@ -203,7 +199,7 @@ async def verify_model_config_connectivity(model_config: dict): Args: model_config: Model configuration dictionary, containing necessary connection parameters Returns: - ModelResponse: Contains the result of the connectivity test + dict: Contains the result of the connectivity test """ try: model_name = model_config.get("model_name", "") @@ -219,46 +215,23 @@ async def verify_model_config_connectivity(model_config: dict): model_name, model_type, model_base_url, model_api_key, embedding_dim ) except ValueError as e: - logger.warning( - f"UNCONNECTED: {model_name}; Base URL: {model_base_url}; API Key: {model_api_key}; Error: {str(e)}") - return ModelResponse( - code=400, - message=str(e), - data={ - "connectivity": False, - "message": str(e), - "error_code": "MODEL_VALIDATION_ERROR", - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value - } - ) + logger.warning(f"UNCONNECTED: {model_name}; Base URL: {model_base_url}; API Key: {model_api_key}; Error: {str(e)}") + return { + "connectivity": False, + "model_name": model_name + } - connect_status = ModelConnectStatusEnum.AVAILABLE.value if connectivity else ModelConnectStatusEnum.UNAVAILABLE.value - status_code = "MODEL_VALIDATION_SUCCESS" if connectivity else "MODEL_VALIDATION_FAILED" + return { + "connectivity": connectivity, + "model_name": model_name, + } - return ModelResponse( - code=200, - message="", - data={ - "connectivity": connectivity, - "error_code": status_code, - "model_name": model_name, - "connect_status": connect_status - } - ) except Exception as e: - error_message = str(e) - logger.warning( - f"UNCONNECTED: {model_name}; Base URL: {model_base_url}; API Key: {model_api_key}; Error: {error_message}") - return ModelResponse( - code=500, - message="", - data={ - "connectivity": False, - "error_code": "MODEL_VALIDATION_ERROR_UNKNOWN", - "error_details": error_message, - "connect_status": ModelConnectStatusEnum.UNAVAILABLE.value - } - ) + logger.error(f"Failed to check connectivity of models: {str(e)}") + return { + "connectivity": False, + "model_name": model_config.get("model_name", "UNKNOWN_MODEL") + } async def embedding_dimension_check(model_config: dict): @@ -272,7 +245,9 @@ async def embedding_dimension_check(model_config: dict): model_name, model_type, model_base_url, model_api_key ) return dimension + except ValueError as e: + logger.error(f"Error checking embedding dimension: {str(e)}") + return 0 except Exception as e: - logger.warning( - f"UNCONNECTED: {model_name}; Base URL: {model_base_url}; Error: {str(e)}") + logger.error(f"Error checking embedding dimension: {model_name}; Base URL: {model_base_url}; Error: {str(e)}") return 0 diff --git a/backend/services/model_management_service.py b/backend/services/model_management_service.py new file mode 100644 index 000000000..6c293507a --- /dev/null +++ b/backend/services/model_management_service.py @@ -0,0 +1,242 @@ +import logging +from typing import List, Dict, Any + +from consts.model import ModelConnectStatusEnum +from consts.provider import ProviderEnum, SILICON_BASE_URL + +from database.model_management_db import ( + create_model_record, + delete_model_record, + get_model_by_display_name, + get_model_records, + get_models_by_tenant_factory_type, + update_model_record, +) +from services.model_provider_service import ( + prepare_model_dict, + merge_existing_model_tokens, + get_provider_models, +) +from services.model_health_service import embedding_dimension_check +from utils.model_name_utils import ( + add_repo_to_name, + split_display_name, + split_repo_name, + sort_models_by_id, +) + +logger = logging.getLogger("model_management_service") + + +async def create_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict[str, Any]): + """Create a single model record for the given tenant. + + Raises ValueError on display name conflict or invalid input. + """ + try: + # Replace localhost with host.docker.internal for local llm + model_base_url = model_data.get("base_url", "") + if "localhost" in model_base_url or "127.0.0.1" in model_base_url: + model_data["base_url"] = ( + model_base_url.replace("localhost", "host.docker.internal") + .replace("127.0.0.1", "host.docker.internal") + ) + + # Split model_name into repo and name + model_repo, model_name = split_repo_name(model_data["model_name"]) if model_data.get("model_name") else ("", "") + model_data["model_repo"] = model_repo if model_repo else "" + model_data["model_name"] = model_name + + if not model_data.get("display_name"): + model_data["display_name"] = split_display_name(model_data.get("model_name", "")) + + # Use NOT_DETECTED status as default + model_data["connect_status"] = model_data.get("connect_status") or ModelConnectStatusEnum.NOT_DETECTED.value + + # Check display name conflict scoped by tenant + if model_data.get("display_name"): + existing_model_by_display = get_model_by_display_name(model_data["display_name"], tenant_id) + if existing_model_by_display: + logging.error( + f"Name {model_data['display_name']} is already in use, please choose another display name") + raise ValueError(f"Name {model_data['display_name']} is already in use, please choose another display name") + + # If embedding or multi_embedding, set max_tokens via embedding dimension check + if model_data.get("model_type") in ("embedding", "multi_embedding"): + model_data["max_tokens"] = await embedding_dimension_check(model_data) + + is_multimodal = model_data.get("model_type") == "multi_embedding" + + if is_multimodal: + # Create multi_embedding record + create_model_record(model_data, user_id, tenant_id) + logging.debug(f"Multimodal embedding model {model_data['display_name']} created successfully") + + # Create embedding record variant + embedding_data = model_data.copy() + embedding_data["model_type"] = "embedding" + create_model_record(embedding_data, user_id, tenant_id) + logging.debug(f"Embedding model {embedding_data['display_name']} created successfully") + + # Non-multimodal + create_model_record(model_data, user_id, tenant_id) + logging.debug(f"Model {model_data['display_name']} created successfully") + except Exception as e: + logging.error(f"Failed to create model: {str(e)}") + raise Exception(f"Failed to create model: {str(e)}") + + +async def create_provider_models_for_tenant(tenant_id: str, provider_request: Dict[str, Any]): + """Create/refresh provider models in memory and merge existing attributes. + + Returns content dict with list data. Does not persist new records. + """ + try: + # Get provider model list + model_list = await get_provider_models(provider_request) + + # Merge existing model's max_tokens attribute + model_list = merge_existing_model_tokens(model_list, tenant_id, provider_request["provider"], provider_request["model_type"]) + + # Sort model list by ID + model_list = sort_models_by_id(model_list) + + logging.debug(f"Provider model {provider_request['provider']} created successfully") + return model_list + except Exception as e: + logging.error(f"Failed to create provider models: {str(e)}") + raise Exception(f"Failed to create provider models: {str(e)}") + + +async def batch_create_models_for_tenant(user_id: str, tenant_id: str, batch_payload: Dict[str, Any]): + """Synchronize provider models for a tenant by creating/updating/deleting records.""" + try: + provider = batch_payload["provider"] + model_type = batch_payload["type"] + model_list: List[Dict[str, Any]] = batch_payload.get("models", []) + model_api_key: str = batch_payload.get("api_key", "") + + if provider == ProviderEnum.SILICON.value: + model_url = SILICON_BASE_URL + else: + model_url = "" + + existing_model_list = get_models_by_tenant_factory_type(tenant_id, provider, model_type) + model_list_ids = {model.get("id") for model in model_list} if model_list else set() + + # Delete existing models not present + for model in existing_model_list: + model_full_name = model["model_repo"] + "/" + model["model_name"] + if model_full_name not in model_list_ids: + delete_model_record(model["model_id"], user_id, tenant_id) + + # Create or update new models + for model in model_list: + _, model_name = split_repo_name(model["id"]) if model.get("id") else ("", "") + model_display_name = split_display_name(model.get("id", "")) + if model_name: + existing_model_by_display = get_model_by_display_name(provider + "/" + model_display_name, tenant_id) + if existing_model_by_display: + # Check if max_tokens has changed + existing_max_tokens = existing_model_by_display.get("max_tokens") + new_max_tokens = model.get("max_tokens") + if new_max_tokens is not None and existing_max_tokens != new_max_tokens: + update_model_record(existing_model_by_display["model_id"], {"max_tokens": new_max_tokens}, user_id) + continue + + model_dict = await prepare_model_dict( + provider=provider, + model=model, + model_url=model_url, + model_api_key=model_api_key, + ) + create_model_record(model_dict, user_id, tenant_id) + logging.debug(f"Model {model['id']} created successfully") + except Exception as e: + logging.error(f"Failed to batch create models: {str(e)}") + raise Exception(f"Failed to batch create models: {str(e)}") + + +async def list_provider_models_for_tenant(tenant_id: str, provider: str, model_type: str): + """List persisted models for a provider/type for a tenant.""" + try: + model_list = get_models_by_tenant_factory_type(tenant_id, provider, model_type) + for model in model_list: + model["id"] = model["model_repo"] + "/" + model["model_name"] + + logging.debug(f"Provider model {provider} created successfully") + return model_list + except Exception as e: + logging.error(f"Failed to list provider models: {str(e)}") + raise Exception(f"Failed to list provider models: {str(e)}") + + +async def update_single_model_for_tenant(user_id: str, tenant_id: str, model_data: Dict[str, Any]): + """Update a single model by its model_id, ensuring display_name uniqueness.""" + try: + existing_model_by_display = get_model_by_display_name(model_data["display_name"], tenant_id) + if existing_model_by_display and existing_model_by_display["model_id"] != model_data["model_id"]: + raise ValueError(f"Name {model_data['display_name']} is already in use, please choose another display name") + + update_model_record(model_data["model_id"], model_data, user_id) + logging.debug(f"Model {model_data['display_name']} updated successfully") + except Exception as e: + logging.error(f"Failed to update model: {str(e)}") + raise Exception(f"Failed to update model: {str(e)}") + + +async def batch_update_models_for_tenant(user_id: str, tenant_id: str, model_list: List[Dict[str, Any]]): + """Batch update models for a tenant.""" + try: + for model in model_list: + update_model_record(model["model_id"], model, user_id) + + logging.debug("Batch update models successfully") + except Exception as e: + logging.error(f"Failed to batch update models: {str(e)}") + raise Exception(f"Failed to batch update models: {str(e)}") + + +async def delete_model_for_tenant(user_id: str, tenant_id: str, display_name: str): + """Delete model(s) by display_name. If embedding/multi_embedding, delete both types.""" + try: + model = get_model_by_display_name(display_name, tenant_id) + if not model: + raise LookupError(f"Model not found: {display_name}") + + deleted_types: List[str] = [] + if model.get("model_type") in ["embedding", "multi_embedding"]: + for t in ["embedding", "multi_embedding"]: + m = get_model_by_display_name(display_name, tenant_id) + if m and m.get("model_type") == t: + delete_model_record(m["model_id"], user_id, tenant_id) + deleted_types.append(t) + else: + delete_model_record(model["model_id"], user_id, tenant_id) + deleted_types.append(model.get("model_type", "unknown")) + + logging.debug(f"Successfully deleted model(s) in types: {', '.join(deleted_types)}") + return display_name + except Exception as e: + logging.error(f"Failed to delete model: {str(e)}") + raise Exception(f"Failed to delete model: {str(e)}") + + +async def list_models_for_tenant(tenant_id: str): + """Get detailed information for all models for a tenant with normalized fields.""" + try: + records = get_model_records(None, tenant_id) + result: List[Dict[str, Any]] = [] + for record in records: + record["model_name"] = add_repo_to_name( + model_repo=record["model_repo"], + model_name=record["model_name"], + ) + record["connect_status"] = ModelConnectStatusEnum.get_value(record.get("connect_status")) + result.append(record) + + logging.debug("Successfully retrieved model list") + return result + except Exception as e: + logging.error(f"Failed to retrieve model list: {str(e)}") + raise Exception(f"Failed to retrieve model list: {str(e)}") diff --git a/backend/services/user_management_service.py b/backend/services/user_management_service.py index 80e88b056..b1c78be0a 100644 --- a/backend/services/user_management_service.py +++ b/backend/services/user_management_service.py @@ -1,5 +1,4 @@ import logging -import os from typing import Optional, Any, Tuple import aiohttp @@ -8,7 +7,7 @@ from pydantic import EmailStr from utils.auth_utils import get_supabase_client, calculate_expires_at, get_jwt_expiry_seconds -from consts.const import INVITE_CODE +from consts.const import INVITE_CODE, SUPABASE_URL, SUPABASE_KEY from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException from database.model_management_db import create_model_record @@ -85,11 +84,8 @@ async def check_auth_service_health(): Check the health status of the authentication service Return (is available, status message) """ - supabase_url = os.getenv("SUPABASE_URL") - supabase_key = os.getenv("SUPABASE_KEY") - - health_url = f'{supabase_url}/auth/v1/health' - headers = {'apikey': supabase_key} + health_url = f'{SUPABASE_URL}/auth/v1/health' + headers = {'apikey': SUPABASE_KEY} async with aiohttp.ClientSession() as session: async with session.get(health_url, headers=headers) as response: @@ -273,8 +269,11 @@ async def refresh_user_token(authorization, refresh_token: str): async def get_session_by_authorization(authorization): + # Extract clean token from authorization header + clean_token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization + # Use the unified token validation function - is_valid, user = validate_token(authorization) + is_valid, user = validate_token(clean_token) if is_valid and user: user_role = "user" # Default role if user.user_metadata and 'role' in user.user_metadata: diff --git a/backend/utils/auth_utils.py b/backend/utils/auth_utils.py index 04b34f2e4..1282addde 100644 --- a/backend/utils/auth_utils.py +++ b/backend/utils/auth_utils.py @@ -1,7 +1,6 @@ +import logging import hashlib import hmac -import logging -import os import time from datetime import datetime, timedelta from typing import Optional, Tuple @@ -10,16 +9,10 @@ from fastapi import Request from supabase import create_client -from consts.const import DEFAULT_TENANT_ID, DEFAULT_USER_ID, IS_SPEED_MODE +from consts.const import DEFAULT_TENANT_ID, DEFAULT_USER_ID, IS_SPEED_MODE, SUPABASE_URL, SUPABASE_KEY, DEBUG_JWT_EXPIRE_SECONDS from consts.exceptions import LimitExceededError, SignatureValidationError, UnauthorizedError from database.user_tenant_db import get_user_tenant_by_user_id -# Get Supabase configuration -SUPABASE_URL = os.getenv('SUPABASE_URL') -SUPABASE_KEY = os.getenv('SUPABASE_KEY', '') -# Debug JWT expiration time (seconds), not set or 0 means not effective -DEBUG_JWT_EXPIRE_SECONDS = int(os.getenv('DEBUG_JWT_EXPIRE_SECONDS', '0') or 0) - # Module logger logger = logging.getLogger(__name__) @@ -203,7 +196,7 @@ def validate_aksk_authentication(headers: dict, request_body: str = "") -> bool: def get_supabase_client(): - """Get Supabase client instance""" + """Get Supabase client instance with service key for admin operations""" try: return create_client(SUPABASE_URL, SUPABASE_KEY) except Exception as e: diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 0b135c4ba..438b2f67a 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -31,7 +31,8 @@ export const API_ENDPOINTS = { list: `${API_BASE_URL}/agent/list`, delete: `${API_BASE_URL}/agent`, getCreatingSubAgentId: `${API_BASE_URL}/agent/get_creating_sub_agent_id`, - stop: (conversationId: number) => `${API_BASE_URL}/agent/stop/${conversationId}`, + stop: (conversationId: number) => + `${API_BASE_URL}/agent/stop/${conversationId}`, export: `${API_BASE_URL}/agent/export`, import: `${API_BASE_URL}/agent/import`, searchInfo: `${API_BASE_URL}/agent/search_info`, @@ -58,43 +59,54 @@ export const API_ENDPOINTS = { storage: { upload: `${API_BASE_URL}/file/storage`, files: `${API_BASE_URL}/file/storage`, - file: (objectName: string, download: string = 'ignore') => `${API_BASE_URL}/file/storage/${objectName}?download=${download}`, - delete: (objectName: string) => `${API_BASE_URL}/file/storage/${objectName}`, + file: (objectName: string, download: string = "ignore") => + `${API_BASE_URL}/file/storage/${objectName}?download=${download}`, + delete: (objectName: string) => + `${API_BASE_URL}/file/storage/${objectName}`, preprocess: `${API_BASE_URL}/file/preprocess`, }, proxy: { - image: (url: string) => `${API_BASE_URL}/image?url=${encodeURIComponent(url)}`, + image: (url: string) => + `${API_BASE_URL}/image?url=${encodeURIComponent(url)}`, }, model: { - // Basic health check - healthcheck: `${API_BASE_URL}/me/healthcheck`, - // Official model service officialModelList: `${API_BASE_URL}/me/model/list`, - + officialModelHealthcheck: `${API_BASE_URL}/me/healthcheck`, + // Custom model service customModelList: `${API_BASE_URL}/model/list`, customModelCreate: `${API_BASE_URL}/model/create`, - customModelCreateProvider: `${API_BASE_URL}/model/create_provider`, - customModelBatchCreate: `${API_BASE_URL}/model/batch_create_models`, + customModelCreateProvider: `${API_BASE_URL}/model/provider/create`, + customModelBatchCreate: `${API_BASE_URL}/model/provider/batch_create`, getProviderSelectedModalList: `${API_BASE_URL}/model/provider/list`, - customModelDelete: (displayName: string) => `${API_BASE_URL}/model/delete?display_name=${encodeURIComponent(displayName)}`, - customModelHealthcheck: (displayName: string) => `${API_BASE_URL}/model/healthcheck?display_name=${encodeURIComponent(displayName)}`, - verifyModelConfig: `${API_BASE_URL}/model/verify_config`, - updateSingleModel: `${API_BASE_URL}/model/update_single_model`, - updateBatchModel: `${API_BASE_URL}/model/batch_update_models`, + customModelDelete: (displayName: string) => + `${API_BASE_URL}/model/delete?display_name=${encodeURIComponent( + displayName + )}`, + customModelHealthcheck: (displayName: string) => + `${API_BASE_URL}/model/healthcheck?display_name=${encodeURIComponent( + displayName + )}`, + verifyModelConfig: `${API_BASE_URL}/model/temporary_healthcheck`, + updateSingleModel: `${API_BASE_URL}/model/update`, + updateBatchModel: `${API_BASE_URL}/model/batch_update`, }, knowledgeBase: { // Elasticsearch service health: `${API_BASE_URL}/indices/health`, indices: `${API_BASE_URL}/indices`, checkName: (name: string) => `${API_BASE_URL}/indices/check_exist/${name}`, - listFiles: (indexName: string) => `${API_BASE_URL}/indices/${indexName}/files`, + listFiles: (indexName: string) => + `${API_BASE_URL}/indices/${indexName}/files`, indexDetail: (indexName: string) => `${API_BASE_URL}/indices/${indexName}`, - summary: (indexName: string) => `${API_BASE_URL}/summary/${indexName}/auto_summary`, - changeSummary: (indexName: string) => `${API_BASE_URL}/summary/${indexName}/summary`, - getSummary: (indexName: string) => `${API_BASE_URL}/summary/${indexName}/summary`, - + summary: (indexName: string) => + `${API_BASE_URL}/summary/${indexName}/auto_summary`, + changeSummary: (indexName: string) => + `${API_BASE_URL}/summary/${indexName}/summary`, + getSummary: (indexName: string) => + `${API_BASE_URL}/summary/${indexName}/summary`, + // File upload service upload: `${API_BASE_URL}/file/upload`, process: `${API_BASE_URL}/file/process`, @@ -121,9 +133,11 @@ export const API_ENDPOINTS = { load: `${API_BASE_URL}/memory/config/load`, set: `${API_BASE_URL}/memory/config/set`, disableAgentAdd: `${API_BASE_URL}/memory/config/disable_agent`, - disableAgentRemove: (agentId: string | number) => `${API_BASE_URL}/memory/config/disable_agent/${agentId}`, + disableAgentRemove: (agentId: string | number) => + `${API_BASE_URL}/memory/config/disable_agent/${agentId}`, disableUserAgentAdd: `${API_BASE_URL}/memory/config/disable_useragent`, - disableUserAgentRemove: (agentId: string | number) => `${API_BASE_URL}/memory/config/disable_useragent/${agentId}`, + disableUserAgentRemove: (agentId: string | number) => + `${API_BASE_URL}/memory/config/disable_useragent/${agentId}`, }, // ---------------- Memory CRUD ---------------- @@ -131,10 +145,11 @@ export const API_ENDPOINTS = { add: `${API_BASE_URL}/memory/add`, search: `${API_BASE_URL}/memory/search`, list: `${API_BASE_URL}/memory/list`, - delete: (memoryId: string | number) => `${API_BASE_URL}/memory/delete/${memoryId}`, + delete: (memoryId: string | number) => + `${API_BASE_URL}/memory/delete/${memoryId}`, clear: `${API_BASE_URL}/memory/clear`, }, - } + }, }; // Common error handling diff --git a/frontend/services/memoryService.ts b/frontend/services/memoryService.ts index ea15eb971..37379fed1 100644 --- a/frontend/services/memoryService.ts +++ b/frontend/services/memoryService.ts @@ -1,21 +1,23 @@ -import i18next from 'i18next' +import i18next from 'i18next'; -import { API_ENDPOINTS, fetchWithErrorHandling } from "./api" -import { fetchAllAgents } from "./agentConfigService" +import { API_ENDPOINTS, fetchWithErrorHandling } from "./api"; +import { fetchAllAgents } from "./agentConfigService"; -import { MEMORY_SHARE_STRATEGY, MemoryShareStrategy } from "@/const/memoryConfig" -import { MemoryItem, MemoryGroup } from "@/types/memory" +import { MemoryItem, MemoryGroup } from "@/types/memory"; import { getAuthHeaders } from '@/lib/auth'; // --------------------------------------------------------------------------- // Error message translation helper // --------------------------------------------------------------------------- function getFriendlyErrorMessage(raw: string): string { - let msg = raw + let msg = raw; try { - const obj = JSON.parse(raw) - if (obj && typeof obj.message === "string") { - msg = obj.message + const obj = JSON.parse(raw); + // Backend now raises HTTPException with { detail } + if (obj && typeof obj.detail === "string") { + msg = obj.detail; + } else if (obj && typeof obj.message === "string") { + msg = obj.message; } } catch (_) { // ignore JSON parse errors @@ -23,14 +25,14 @@ function getFriendlyErrorMessage(raw: string): string { // Keyword mapping to user-friendly Chinese prompts if (/AuthenticationException/i.test(msg)) { - return "ElasticSearch数据库鉴权失败" + return "Elasticsearch authentication failed"; } else if (/ConnectionTimeout/i.test(msg)) { - return "大语言模型连接超时" + return "Connection to language model timed out"; } else if (/unhashable type: 'slice'/i.test(msg)) { - return "后端数据切片错误,请联系管理员" + return "Backend data slicing error. Please contact administrator"; } - return msg + return msg; } /** @@ -43,84 +45,91 @@ function getFriendlyErrorMessage(raw: string): string { // --------------------------------------------------------------------------- // Helper for unified JSON request/response handling // --------------------------------------------------------------------------- -async function requestJson(url: string, options: RequestInit = {}): Promise { - const resp = await fetchWithErrorHandling(url, options) - return resp.json() +async function requestJson( + url: string, + options: RequestInit = {} +): Promise { + const resp = await fetchWithErrorHandling(url, options); + return resp.json(); } // --------------------------------------------------------------------------- // Configuration helpers // --------------------------------------------------------------------------- export interface MemoryConfig { - memoryEnabled: boolean - shareOption: MemoryShareStrategy - disableAgentIds: string[] - disableUserAgentIds: string[] + memoryEnabled: boolean; + shareOption: "always" | "ask" | "never"; + disableAgentIds: string[]; + disableUserAgentIds: string[]; } export async function loadMemoryConfig(): Promise { try { const res = await requestJson(API_ENDPOINTS.memory.config.load, { method: "GET", - headers: getAuthHeaders() - }) - - if (res?.status !== "success") { - throw new Error(res?.message || "Load memory config failed") - } + headers: getAuthHeaders(), + }); - const cfg = res.content || {} + // Backend returns plain config object directly + const cfg = res || {}; - const memorySwitchVal: string = cfg.MEMORY_SWITCH ?? cfg.memory_switch ?? "Y" - const shareVal: string = cfg.MEMORY_AGENT_SHARE ?? cfg.memory_agent_share ?? MEMORY_SHARE_STRATEGY.ALWAYS - const disableAgentIds: string[] = cfg.DISABLE_AGENT_ID ?? cfg.disable_agent_id ?? [] - const disableUserAgentIds: string[] = cfg.DISABLE_USERAGENT_ID ?? cfg.disable_useragent_id ?? [] + const memorySwitchVal: string = + cfg.MEMORY_SWITCH ?? cfg.memory_switch ?? "Y"; + const shareVal: string = + cfg.MEMORY_AGENT_SHARE ?? cfg.memory_agent_share ?? "always"; + const disableAgentIds: string[] = + cfg.DISABLE_AGENT_ID ?? cfg.disable_agent_id ?? []; + const disableUserAgentIds: string[] = + cfg.DISABLE_USERAGENT_ID ?? cfg.disable_useragent_id ?? []; return { memoryEnabled: memorySwitchVal === "Y", - shareOption: (shareVal || MEMORY_SHARE_STRATEGY.ALWAYS) as MemoryShareStrategy, + shareOption: (shareVal || "always") as "always" | "ask" | "never", disableAgentIds, disableUserAgentIds, - } + }; } catch (e) { - console.error("loadMemoryConfig error", e) + console.error("loadMemoryConfig error", e); // fall back to defaults return { memoryEnabled: true, - shareOption: MEMORY_SHARE_STRATEGY.ALWAYS, + shareOption: "always", disableAgentIds: [], disableUserAgentIds: [], - } + }; } } export async function setMemorySwitch(enabled: boolean): Promise { try { - const body = { key: "MEMORY_SWITCH", value: enabled } + const body = { key: "MEMORY_SWITCH", value: enabled }; const res = await requestJson(API_ENDPOINTS.memory.config.set, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify(body), - }) - return res?.status === "success" + }); + // Backend returns { success: true } on OK + return !!res?.success; } catch (e) { - console.error("setMemorySwitch error", e) - return false + console.error("setMemorySwitch error", e); + return false; } } -export async function setMemoryAgentShare(option: MemoryShareStrategy): Promise { +export async function setMemoryAgentShare( + option: "always" | "ask" | "never" +): Promise { try { - const body = { key: "MEMORY_AGENT_SHARE", value: option } + const body = { key: "MEMORY_AGENT_SHARE", value: option }; const res = await requestJson(API_ENDPOINTS.memory.config.set, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify(body), - }) - return res?.status === "success" + }); + return !!res?.success; } catch (e) { - console.error("setMemoryAgentShare error", e) - return false + console.error("setMemoryAgentShare error", e); + return false; } } @@ -131,87 +140,104 @@ export async function addDisabledAgentId(agentId: string): Promise { method: "POST", headers: getAuthHeaders(), body: JSON.stringify({ agent_id: agentId }), - }) - return res?.status === "success" + }); + return !!res?.success; } catch (e) { - console.error("addDisabledAgentId error", e) - return false + console.error("addDisabledAgentId error", e); + return false; } } export async function removeDisabledAgentId(agentId: string): Promise { try { - const res = await requestJson(API_ENDPOINTS.memory.config.disableAgentRemove(agentId), { - method: "DELETE", - headers: getAuthHeaders(), - }) - return res?.status === "success" + const res = await requestJson( + API_ENDPOINTS.memory.config.disableAgentRemove(agentId), + { + method: "DELETE", + headers: getAuthHeaders(), + } + ); + return !!res?.success; } catch (e) { - console.error("removeDisabledAgentId error", e) - return false + console.error("removeDisabledAgentId error", e); + return false; } } -export async function addDisabledUserAgentId(agentId: string): Promise { +export async function addDisabledUserAgentId( + agentId: string +): Promise { try { - const res = await requestJson(API_ENDPOINTS.memory.config.disableUserAgentAdd, { - method: "POST", - headers: getAuthHeaders(), - body: JSON.stringify({ agent_id: agentId }), - }) - return res?.status === "success" + const res = await requestJson( + API_ENDPOINTS.memory.config.disableUserAgentAdd, + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ agent_id: agentId }), + } + ); + return !!res?.success; } catch (e) { - console.error("addDisabledUserAgentId error", e) - return false + console.error("addDisabledUserAgentId error", e); + return false; } } -export async function removeDisabledUserAgentId(agentId: string): Promise { +export async function removeDisabledUserAgentId( + agentId: string +): Promise { try { - const res = await requestJson(API_ENDPOINTS.memory.config.disableUserAgentRemove(agentId), { - method: "DELETE", - headers: getAuthHeaders(), - }) - return res?.status === "success" + const res = await requestJson( + API_ENDPOINTS.memory.config.disableUserAgentRemove(agentId), + { + method: "DELETE", + headers: getAuthHeaders(), + } + ); + return !!res?.success; } catch (e) { - console.error("removeDisabledUserAgentId error", e) - return false + console.error("removeDisabledUserAgentId error", e); + return false; } } // --------------------------------------------------------------------------- // Memory list helpers // --------------------------------------------------------------------------- -async function listMemories(memoryLevel: string, agentId?: string): Promise<{ items: MemoryItem[]; total: number }> { - const params = new URLSearchParams({ memory_level: memoryLevel }) - if (agentId) params.append("agent_id", agentId) +async function listMemories( + memoryLevel: string, + agentId?: string +): Promise<{ items: MemoryItem[]; total: number }> { + const params = new URLSearchParams({ memory_level: memoryLevel }); + if (agentId) params.append("agent_id", agentId); - const url = `${API_ENDPOINTS.memory.entry.list}?${params.toString()}` + const url = `${API_ENDPOINTS.memory.entry.list}?${params.toString()}`; try { - const res = await requestJson(url, { method: "GET", headers: getAuthHeaders() }) - if (res?.status !== "success") { - throw new Error(res?.message || "listMemories failed") - } - const content = res.content || {} - const items: MemoryItem[] = content.items ?? content ?? [] - const total: number = content.total ?? items.length - return { items, total } + const res = await requestJson(url, { + method: "GET", + headers: getAuthHeaders(), + }); + // Backend returns payload directly (list or object with items/total) + const content = res ?? {}; + const items: MemoryItem[] = content.items ?? res ?? []; + const total: number = content.total ?? items.length; + return { items, total }; } catch (e) { - console.error("listMemories error", e) + console.error("listMemories error", e); if (e instanceof Error) { - throw new Error(getFriendlyErrorMessage(e.message || "")) + throw new Error(getFriendlyErrorMessage(e.message || "")); } - throw new Error(i18next.t('memoryService.loadMemoryError')) + throw new Error(i18next.t("memoryService.loadMemoryError")); } } export async function fetchTenantSharedGroup(): Promise { - const { items } = await listMemories("tenant") + const { items } = await listMemories("tenant"); return { - title: i18next.t('memoryService.tenantSharedGroupTitle'), + title: i18next.t("memoryService.tenantSharedGroupTitle"), key: "tenant", items, - } + }; } export async function fetchAgentSharedGroups(): Promise { @@ -219,53 +245,59 @@ export async function fetchAgentSharedGroups(): Promise { const [{ items }, agentsRes] = await Promise.all([ listMemories("agent"), fetchAllAgents(), - ]) + ]); // First group results with memories by agent_id - const groupMap: Record = {} + const groupMap: Record = {}; items.forEach((item) => { - if (!item.agent_id) return - if (!groupMap[item.agent_id]) groupMap[item.agent_id] = [] - groupMap[item.agent_id].push(item) - }) + if (!item.agent_id) return; + if (!groupMap[item.agent_id]) groupMap[item.agent_id] = []; + groupMap[item.agent_id].push(item); + }); - // Need to complete "no memory" Agent groups later - const agentList: Array<{ agent_id: string; name?: string; display_name?: string }> = (agentsRes as any)?.success ? (agentsRes as any).data : [] + // Complete groups with agents that have no memories + const agentList: Array<{ + agent_id: string; + name?: string; + display_name?: string; + }> = (agentsRes as any)?.success ? (agentsRes as any).data : []; - const groups: MemoryGroup[] = [] + const groups: MemoryGroup[] = []; // Build groups in Agent list order to ensure completeness agentList.forEach((agent) => { - const agentId = agent.agent_id - const list = groupMap[agentId] || [] + const agentId = agent.agent_id; + const list = groupMap[agentId] || []; groups.push({ - title: i18next.t('memoryService.agentSharedGroupTitle', { agentName: agent.display_name || agent.name || agentId }), + title: i18next.t("memoryService.agentSharedGroupTitle", { + agentName: agent.display_name || agent.name || agentId, + }), key: `agent-${agentId}`, items: list, - }) - }) + }); + }); - // If still no Agent information, return placeholder group + // If still no agent info, return placeholder group if (groups.length === 0) { return [ { - title: i18next.t('memoryService.agentSharedPlaceholder'), + title: i18next.t("memoryService.agentSharedPlaceholder"), key: "agent-placeholder", items: [], }, - ] + ]; } - return groups + return groups; } export async function fetchUserPersonalGroup(): Promise { - const { items } = await listMemories("user") + const { items } = await listMemories("user"); return { - title: i18next.t('memoryService.userPersonalGroupTitle'), + title: i18next.t("memoryService.userPersonalGroupTitle"), key: "user-personal", items, - } + }; } export async function fetchUserAgentGroups(): Promise { @@ -273,49 +305,57 @@ export async function fetchUserAgentGroups(): Promise { const [{ items }, agentsRes] = await Promise.all([ listMemories("user_agent"), fetchAllAgents(), - ]) + ]); - const groupMap: Record = {} + const groupMap: Record = {}; items.forEach((item) => { - if (!item.agent_id) return - if (!groupMap[item.agent_id]) groupMap[item.agent_id] = [] - groupMap[item.agent_id].push(item) - }) + if (!item.agent_id) return; + if (!groupMap[item.agent_id]) groupMap[item.agent_id] = []; + groupMap[item.agent_id].push(item); + }); - const agentList: Array<{ agent_id: string | number; name?: string; display_name?: string }> = (agentsRes as any)?.success ? (agentsRes as any).data : [] + const agentList: Array<{ + agent_id: string | number; + name?: string; + display_name?: string; + }> = (agentsRes as any)?.success ? (agentsRes as any).data : []; - const groups: MemoryGroup[] = [] + const groups: MemoryGroup[] = []; agentList.forEach((agent) => { - const agentId = String(agent.agent_id) - const list = groupMap[agentId] || [] + const agentId = String(agent.agent_id); + const list = groupMap[agentId] || []; groups.push({ - title: i18next.t('memoryService.userAgentGroupTitle', { agentName: agent.display_name || agent.name || agentId }), + title: i18next.t("memoryService.userAgentGroupTitle", { + agentName: agent.display_name || agent.name || agentId, + }), key: `user-agent-${agentId}`, items: list, - }) - }) + }); + }); Object.entries(groupMap).forEach(([agentId, list]) => { if (!agentList.some((a) => String(a.agent_id) === agentId)) { groups.push({ - title: i18next.t('memoryService.userAgentGroupTitle', { agentName: list[0]?.agent_name || agentId }), + title: i18next.t("memoryService.userAgentGroupTitle", { + agentName: list[0]?.agent_name || agentId, + }), key: `user-agent-${agentId}`, items: list, - }) + }); } - }) + }); if (groups.length === 0) { return [ { - title: i18next.t('memoryService.userAgentPlaceholder'), + title: i18next.t("memoryService.userAgentPlaceholder"), key: "user-agent-placeholder", items: [], }, - ] + ]; } - return groups + return groups; } // --------------------------------------------------------------------------- @@ -334,47 +374,60 @@ export async function addMemory( memory_level: memoryLevel, infer, ...(agentId && { agent_id: agentId }), - } + }; const res = await requestJson(API_ENDPOINTS.memory.entry.add, { method: "POST", headers: getAuthHeaders(), body: JSON.stringify(body), - }) - return res?.status === "success" + }); + // Backend returns inserted info or payload directly on success + return !!res; } catch (e) { - console.error("addMemory error", e) - throw e + console.error("addMemory error", e); + throw e; } } -export async function clearMemory(memoryLevel: string, agentId?: string): Promise<{ deleted_count: number; total_count: number }> { +export async function clearMemory( + memoryLevel: string, + agentId?: string +): Promise<{ deleted_count: number; total_count: number }> { try { - const params = new URLSearchParams({ memory_level: memoryLevel }) - if (agentId) params.append("agent_id", agentId) - const url = `${API_ENDPOINTS.memory.entry.clear}?${params.toString()}` - - const res = await requestJson(url, { method: "DELETE", headers: getAuthHeaders() }) - if (res?.status !== "success") { - throw new Error(res?.message || "Clear memory failed") - } - const result = res.content || { deleted_count: 0, total_count: 0 } - return result + const params = new URLSearchParams({ memory_level: memoryLevel }); + if (agentId) params.append("agent_id", agentId); + const url = `${API_ENDPOINTS.memory.entry.clear}?${params.toString()}`; + + const res = await requestJson(url, { + method: "DELETE", + headers: getAuthHeaders(), + }); + const result = res || { deleted_count: 0, total_count: 0 }; + return result; } catch (e) { - console.error("clearMemory error", e) - throw e + console.error("clearMemory error", e); + throw e; } } -export async function deleteMemory(memoryId: string, memoryLevel: string, agentId?: string): Promise { +export async function deleteMemory( + memoryId: string, + memoryLevel: string, + agentId?: string +): Promise { try { - const params = new URLSearchParams({ memory_level: memoryLevel }) - if (agentId) params.append("agent_id", agentId) - const url = `${API_ENDPOINTS.memory.entry.delete(memoryId)}?${params.toString()}` - - const res = await requestJson(url, { method: "DELETE", headers: getAuthHeaders() }) - return res?.status === "success" + const params = new URLSearchParams({ memory_level: memoryLevel }); + if (agentId) params.append("agent_id", agentId); + const url = `${API_ENDPOINTS.memory.entry.delete( + memoryId + )}?${params.toString()}`; + + const res = await requestJson(url, { + method: "DELETE", + headers: getAuthHeaders(), + }); + return !!res; } catch (e) { - console.error("deleteMemory error", e) - throw e + console.error("deleteMemory error", e); + throw e; } } diff --git a/frontend/services/modelEngineService.ts b/frontend/services/modelEngineService.ts index f29936027..8e8a05805 100644 --- a/frontend/services/modelEngineService.ts +++ b/frontend/services/modelEngineService.ts @@ -1,6 +1,6 @@ "use client" -import { API_ENDPOINTS } from './api'; +import { API_ENDPOINTS, ApiError } from './api'; import { CONNECTION_STATUS, ConnectionStatus } from '@/const/modelConfig'; import { ModelEngineCheckResult } from '../types/modelConfig'; @@ -19,7 +19,7 @@ const modelEngineService = { */ checkConnection: async (): Promise => { try { - const response = await fetch(API_ENDPOINTS.model.healthcheck, { + const response = await fetch(API_ENDPOINTS.model.officialModelHealthcheck, { method: "GET" }) @@ -28,20 +28,10 @@ const modelEngineService = { if (response.ok) { try { const resp = await response.json() - // Parse the data returned by the API - if (resp.status === "Connected") { - status = CONNECTION_STATUS.SUCCESS - } - else if (resp.status === "Disconnected") { - status = CONNECTION_STATUS.ERROR - } + status = resp.connectivity ? CONNECTION_STATUS.SUCCESS : CONNECTION_STATUS.ERROR } catch (parseError) { - // JSON parsing failed, treat as connection failure console.error("Response data parsing failed:", parseError) - status = CONNECTION_STATUS.ERROR } - } else { - status = CONNECTION_STATUS.ERROR } return { @@ -49,7 +39,10 @@ const modelEngineService = { lastChecked: new Date().toLocaleTimeString() } } catch (error) { - console.error("Failed to check ModelEngine connection status:", error) + // only print non ApiError + if (!(error && error instanceof ApiError)) { + console.error("Failed to check ModelEngine connection status:", error) + } return { status: CONNECTION_STATUS.ERROR, lastChecked: new Date().toLocaleTimeString() diff --git a/frontend/services/modelService.ts b/frontend/services/modelService.ts index 443fd53a8..d2cbf298a 100644 --- a/frontend/services/modelService.ts +++ b/frontend/services/modelService.ts @@ -1,8 +1,14 @@ -"use client" +"use client"; -import { API_ENDPOINTS } from './api' +import { API_ENDPOINTS } from "./api"; -import { ModelOption, ModelType, ModelConnectStatus, ModelValidationResponse, ModelSource, ApiResponse } from '../types/modelConfig' +import { + ModelOption, + ModelType, + ModelConnectStatus, + ModelValidationResponse, + ModelSource, +} from "@/types/modelConfig"; import { getAuthHeaders } from '@/lib/auth' import {STATUS_CODES} from "@/const/auth"; @@ -11,19 +17,19 @@ import { MODEL_TYPES, MODEL_SOURCES } from '@/const/modelConfig'; // Error class export class ModelError extends Error { constructor(message: string, public code?: number) { - super(message) - this.name = 'ModelError' + super(message); + this.name = "ModelError"; // Override the stack property to only return the message - Object.defineProperty(this, 'stack', { - get: function() { - return this.message - } - }) + Object.defineProperty(this, "stack", { + get: function () { + return this.message; + }, + }); } // Override the toString method to only return the message toString() { - return this.message + return this.message; } } @@ -33,20 +39,20 @@ export const modelService = { getOfficialModels: async (): Promise => { try { const response = await fetch(API_ENDPOINTS.model.officialModelList, { - headers: getAuthHeaders() - }) - const result = await response.json() + headers: getAuthHeaders(), + }); + const result = await response.json(); if (response.status === STATUS_CODES.SUCCESS && result.data) { - const modelOptions: ModelOption[] = [] + const modelOptions: ModelOption[] = []; const typeMap: Record = { embed: MODEL_TYPES.EMBEDDING, chat: MODEL_TYPES.LLM, asr: MODEL_TYPES.STT, tts: MODEL_TYPES.TTS, rerank: MODEL_TYPES.RERANK, - vlm: MODEL_TYPES.VLM - } + vlm: MODEL_TYPES.VLM, + }; for (const model of result.data) { if (typeMap[model.type]) { @@ -58,19 +64,19 @@ export const modelService = { source: MODEL_SOURCES.OPENAI_API_COMPATIBLE, apiKey: model.api_key, apiUrl: model.base_url, - displayName: model.id - }) + displayName: model.id, + }); } } - return modelOptions + return modelOptions; } // If API call was not successful, return empty array - return [] + return []; } catch (error) { // In case of any error, return empty array - console.warn('Failed to load official models:', error) - return [] + console.warn("Failed to load official models:", error); + return []; } }, @@ -78,12 +84,12 @@ export const modelService = { getCustomModels: async (): Promise => { try { const response = await fetch(API_ENDPOINTS.model.customModelList, { - headers: getAuthHeaders() - }) - const result: ApiResponse = await response.json() - - if (result.code === 200 && result.data) { - return result.data.map(model => ({ + headers: getAuthHeaders(), + }); + const result = await response.json(); + + if (response.status === 200 && result.data) { + return result.data.map((model: any) => ({ id: model.model_id, name: model.model_name, type: model.model_type as ModelType, @@ -92,31 +98,35 @@ export const modelService = { apiKey: model.api_key, apiUrl: model.base_url, displayName: model.display_name || model.model_name, - connect_status: model.connect_status as ModelConnectStatus || "not_detected" - })) + connect_status: + (model.connect_status as ModelConnectStatus) || "not_detected", + })); } // If API call was not successful, return empty array - console.warn('Failed to load custom models:', result.message || 'Unknown error') - return [] + console.warn( + "Failed to load custom models:", + result.message || "Unknown error" + ); + return []; } catch (error) { // In case of any error, return empty array - console.warn('Failed to load custom models:', error) - return [] + console.warn("Failed to load custom models:", error); + return []; } }, // Add custom model addCustomModel: async (model: { - name: string - type: ModelType - url: string - apiKey: string - maxTokens: number - displayName?: string + name: string; + type: ModelType; + url: string; + apiKey: string; + maxTokens: number; + displayName?: string; }): Promise => { try { const response = await fetch(API_ENDPOINTS.model.customModelCreate, { - method: 'POST', + method: "POST", headers: getAuthHeaders(), body: JSON.stringify({ model_repo: "", @@ -125,260 +135,301 @@ export const modelService = { base_url: model.url, api_key: model.apiKey, max_tokens: model.maxTokens, - display_name: model.displayName - }) - }) - - const result: ApiResponse = await response.json() - - if (result.code !== 200) { - throw new ModelError(result.message || '添加自定义模型失败', result.code) + display_name: model.displayName, + }), + }); + + const result = await response.json(); + + if (response.status !== 200) { + throw new ModelError( + result.message || "添加自定义模型失败", + response.status + ); } } catch (error) { - if (error instanceof ModelError) throw error - throw new ModelError('添加自定义模型失败', 500) + if (error instanceof ModelError) throw error; + throw new ModelError("添加自定义模型失败", 500); } }, addProviderModel: async (model: { - provider: string - type: ModelType - apiKey: string + provider: string; + type: ModelType; + apiKey: string; }): Promise => { try { - const response = await fetch(API_ENDPOINTS.model.customModelCreateProvider, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ - provider: model.provider, - model_type: model.type, - api_key: model.apiKey - }) - }) - - const result: ApiResponse = await response.json() - - if (result.code !== 200) { - throw new ModelError(result.message || '添加自定义模型失败', result.code) + const response = await fetch( + API_ENDPOINTS.model.customModelCreateProvider, + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + provider: model.provider, + model_type: model.type, + api_key: model.apiKey, + }), + } + ); + + const result = await response.json(); + + if (response.status !== 200) { + throw new ModelError( + result.message || "添加自定义模型失败", + response.status + ); } - return result.data || [] + return result.data || []; } catch (error) { - if (error instanceof ModelError) throw error - throw new ModelError('添加自定义模型失败', 500) + if (error instanceof ModelError) throw error; + throw new ModelError("添加自定义模型失败", 500); } }, addBatchCustomModel: async (model: { - api_key: string, - provider: string, - type: ModelType, - models: any[] + api_key: string; + provider: string; + type: ModelType; + models: any[]; }): Promise => { try { const response = await fetch(API_ENDPOINTS.model.customModelBatchCreate, { - method: 'POST', + method: "POST", headers: getAuthHeaders(), body: JSON.stringify({ api_key: model.api_key, models: model.models, type: model.type, - provider: model.provider - }) - }) - const result: ApiResponse = await response.json() + provider: model.provider, + }), + }); + const result = await response.json(); - if (result.code !== 200) { - throw new ModelError(result.message || '添加自定义模型失败', result.code) + if (response.status !== 200) { + throw new ModelError( + result.message || "添加自定义模型失败", + response.status + ); } - return result.code + return response.status; } catch (error) { - if (error instanceof ModelError) throw error - throw new ModelError('添加自定义模型失败', 500) + if (error instanceof ModelError) throw error; + throw new ModelError("添加自定义模型失败", 500); } }, getProviderSelectedModalList: async (model: { - provider: string, - type: ModelType, - api_key: string + provider: string; + type: ModelType; + api_key: string; }): Promise => { try { - const response = await fetch(API_ENDPOINTS.model.getProviderSelectedModalList, { - method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ - provider: model.provider, - model_type: model.type, - api_key: model.api_key - }) - }) - console.log('getProviderSelectedModalList response', response) - const result: ApiResponse = await response.json() - console.log('getProviderSelectedModalList result', result) - if (result.code !== 200) { - throw new ModelError(result.message || '获取模型列表失败', result.code) + const response = await fetch( + API_ENDPOINTS.model.getProviderSelectedModalList, + { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ + provider: model.provider, + model_type: model.type, + api_key: model.api_key, + }), + } + ); + console.log("getProviderSelectedModalList response", response); + const result = await response.json(); + console.log("getProviderSelectedModalList result", result); + if (response.status !== 200) { + throw new ModelError( + result.message || "获取模型列表失败", + response.status + ); } - return result.data || [] + return result.data || []; } catch (error) { - console.log('getProviderSelectedModalList error', error) - if (error instanceof ModelError) throw error - throw new ModelError('获取模型列表失败', 500) + console.log("getProviderSelectedModalList error", error); + if (error instanceof ModelError) throw error; + throw new ModelError("获取模型列表失败", 500); } }, updateSingleModel: async (model: { - model_id: string, - displayName: string, - url: string, - apiKey: string, - maxTokens?: number, - source?: ModelSource + model_id: string; + displayName: string; + url: string; + apiKey: string; + maxTokens?: number; + source?: ModelSource; }): Promise => { try { const response = await fetch(API_ENDPOINTS.model.updateSingleModel, { - method: 'POST', + method: "POST", headers: getAuthHeaders(), body: JSON.stringify({ model_id: model.model_id, display_name: model.displayName, base_url: model.url, api_key: model.apiKey, - ...(model.maxTokens !== undefined ? { max_tokens: model.maxTokens } : {}), - model_factory: model.source || "OpenAI-API-Compatible" - }) - }) - const result: ApiResponse = await response.json() - if (result.code !== 200) { - throw new ModelError(result.message || "Failed to update the custom model", result.code) + ...(model.maxTokens !== undefined + ? { max_tokens: model.maxTokens } + : {}), + model_factory: model.source || "OpenAI-API-Compatible", + }), + }); + const result = await response.json(); + if (response.status !== 200) { + throw new ModelError( + result.message || "Failed to update the custom model", + response.status + ); } } catch (error) { - if (error instanceof ModelError) throw error - throw new ModelError("Failed to update the custom model", 500) + if (error instanceof ModelError) throw error; + throw new ModelError("Failed to update the custom model", 500); } }, - - updateBatchModel: async (models: { - model_id: string, - apiKey: string, - maxTokens?: number, - }[]): Promise => { + updateBatchModel: async ( + models: { + model_id: string; + apiKey: string; + maxTokens?: number; + }[] + ): Promise => { try { const response = await fetch(API_ENDPOINTS.model.updateBatchModel, { - method: 'POST', + method: "POST", headers: getAuthHeaders(), - body: JSON.stringify(models.map(m => ({ - model_id: m.model_id, - api_key: m.apiKey, - ...(m.maxTokens !== undefined ? { max_tokens: m.maxTokens } : {}), - }))) - }) - const result: ApiResponse = await response.json() - if (result.code !== 200) { - throw new ModelError(result.message || "Failed to update the custom model", result.code) + body: JSON.stringify( + models.map((m) => ({ + model_id: m.model_id, + api_key: m.apiKey, + ...(m.maxTokens !== undefined ? { max_tokens: m.maxTokens } : {}), + })) + ), + }); + const result = await response.json(); + if (response.status !== 200) { + throw new ModelError( + result.message || "Failed to update the custom model", + response.status + ); } - return result + return result; } catch (error) { - if (error instanceof ModelError) throw error - throw new ModelError("Failed to update the custom model", 500) + if (error instanceof ModelError) throw error; + throw new ModelError("Failed to update the custom model", 500); } }, - // Delete custom model deleteCustomModel: async (displayName: string): Promise => { try { - const response = await fetch(API_ENDPOINTS.model.customModelDelete(displayName), { - method: 'POST', - headers: getAuthHeaders() - }) - const result: ApiResponse = await response.json() - if (result.code !== 200) { - throw new ModelError(result.message || '删除自定义模型失败', result.code) + const response = await fetch( + API_ENDPOINTS.model.customModelDelete(displayName), + { + method: "POST", + headers: getAuthHeaders(), + } + ); + const result = await response.json(); + if (response.status !== 200) { + throw new ModelError( + result.message || "删除自定义模型失败", + response.status + ); } } catch (error) { - if (error instanceof ModelError) throw error - throw new ModelError('删除自定义模型失败', 500) + if (error instanceof ModelError) throw error; + throw new ModelError("删除自定义模型失败", 500); } }, // Verify custom model connection - verifyCustomModel: async (displayName: string, signal?: AbortSignal): Promise => { + verifyCustomModel: async ( + displayName: string, + signal?: AbortSignal + ): Promise => { try { - if (!displayName) return false - const response = await fetch(API_ENDPOINTS.model.customModelHealthcheck(displayName), { - method: "POST", - headers: getAuthHeaders(), - signal - }) - const result: ApiResponse<{connectivity: boolean}> = await response.json() - if (result.code === 200 && result.data) { - return result.data.connectivity + if (!displayName) return false; + const response = await fetch( + API_ENDPOINTS.model.customModelHealthcheck(displayName), + { + method: "POST", + headers: getAuthHeaders(), + signal, + } + ); + const result = await response.json(); + await response.json(); + if (response.status === 200 && result.data) { + return result.data.connectivity; } - return false + return false; } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { + if (error instanceof Error && error.name === "AbortError") { console.warn(`验证模型 ${displayName} 连接被取消`); throw error; } - console.error(`验证模型 ${displayName} 连接失败:`, error) - return false + console.error(`验证模型 ${displayName} 连接失败:`, error); + return false; } }, // Verify model configuration connectivity before adding it - verifyModelConfigConnectivity: async (config: { - modelName: string - modelType: ModelType - baseUrl: string - apiKey: string - maxTokens?: number - embeddingDim?: number - }, signal?: AbortSignal): Promise => { + verifyModelConfigConnectivity: async ( + config: { + modelName: string; + modelType: ModelType; + baseUrl: string; + apiKey: string; + maxTokens?: number; + embeddingDim?: number; + }, + signal?: AbortSignal + ): Promise => { try { const response = await fetch(API_ENDPOINTS.model.verifyModelConfig, { method: "POST", - headers: getAuthHeaders(), body: JSON.stringify({ model_name: config.modelName, model_type: config.modelType, base_url: config.baseUrl, api_key: config.apiKey || "sk-no-api-key", max_tokens: config.maxTokens || 4096, - embedding_dim: config.embeddingDim || 1024 + embedding_dim: config.embeddingDim || 1024, }), - signal - }) - - const result: ApiResponse = await response.json() - - if (result.code === 200 && result.data) { + signal, + }); + + const result = await response.json(); + await response.json(); + + if (response.status === 200 && result.data) { return { connectivity: result.data.connectivity, - message: result.data.message || "", - error_code: result.data.error_code, - connect_status: result.data.connect_status - } + model_name: result.data.model_name || "UNKNOWN_MODEL", + }; } - + return { connectivity: false, - message: result.message || '验证失败', - error_code: "MODEL_VALIDATION_FAILED", - connect_status: "unavailable" - } + model_name: result.data?.model_name || "UNKNOWN_MODEL", + }; } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - console.warn('验证模型配置连接被取消'); + if (error instanceof Error && error.name === "AbortError") { + console.warn("Model configuration connectivity verification cancelled"); throw error; } - console.error('验证模型配置连接失败:', error) + console.error( + "Model configuration connectivity verification failed:", + error + ); return { connectivity: false, - message: `验证失败: ${error}`, - error_code: "MODEL_VALIDATION_ERROR_UNKNOWN", - connect_status: "unavailable" - } + model_name: "UNKNOWN_MODEL", + }; } }, -} \ No newline at end of file +}; diff --git a/frontend/types/modelConfig.ts b/frontend/types/modelConfig.ts index 6a29c5900..5c9f0e432 100644 --- a/frontend/types/modelConfig.ts +++ b/frontend/types/modelConfig.ts @@ -95,11 +95,7 @@ export interface GlobalConfig { // Add the type for model validation response with error_code export interface ModelValidationResponse { connectivity: boolean; - message?: string; - error_code?: string; - error_details?: string; - model_name?: string; - connect_status: string; + model_name: string; } // Model engine check result interface diff --git a/test/backend/app/test_me_model_managment_app.py b/test/backend/app/test_me_model_managment_app.py index 7fe3f12d5..6f28e25d2 100644 --- a/test/backend/app/test_me_model_managment_app.py +++ b/test/backend/app/test_me_model_managment_app.py @@ -291,8 +291,7 @@ async def test_check_me_connectivity_success(): # Assertions assert response.status_code == 200 response_data = response.json() - assert response_data["status"] == "Connected" - assert response_data["connect_status"] == "available" + assert response_data["connectivity"] == True @pytest.mark.asyncio @@ -306,10 +305,6 @@ async def test_check_me_connectivity_failure(): response = client.get("/me/healthcheck") assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE - body = response.json() - assert body["status"] == "Disconnected" - assert body["desc"] == "Connection failed." - assert body["connect_status"] == 'unavailable' @pytest.mark.asyncio @@ -326,10 +321,6 @@ async def test_check_me_connectivity_timeout(): # Assertions - route maps TimeoutException -> 408 and returns status/desc/connect_status assert response.status_code == HTTPStatus.REQUEST_TIMEOUT - body = response.json() - assert body["status"] == "Disconnected" - assert body["desc"] == "Connection timeout." - assert body["connect_status"] == "unavailable" @pytest.mark.asyncio @@ -343,10 +334,6 @@ async def test_check_me_connectivity_generic_exception(): # Assertions - route maps generic Exception -> 500 and returns status/desc/connect_status assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR - body = response.json() - assert body["status"] == "Disconnected" - assert body["desc"] == "Unknown error occurred: Unexpected error occurred" - assert body["connect_status"] == "unavailable" @pytest.mark.asyncio diff --git a/test/backend/app/test_memory_config_app.py b/test/backend/app/test_memory_config_app.py new file mode 100644 index 000000000..f944fa90f --- /dev/null +++ b/test/backend/app/test_memory_config_app.py @@ -0,0 +1,355 @@ +from unittest.mock import patch, MagicMock +import sys +import os + +# Add path for correct imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) +sys.modules['boto3'] = MagicMock() + +# Import the modules we need with MinioClient mocked +with patch('database.client.MinioClient', MagicMock()), \ + patch('database.client.minio_client', MagicMock()): + # Import exception classes + from consts.exceptions import UnauthorizedError + from fastapi import FastAPI + from fastapi.testclient import TestClient + from http import HTTPStatus + + # Build app with target router + from apps.memory_config_app import router as memory_router + + app = FastAPI() + app.include_router(memory_router) + client = TestClient(app) + + def _auth_headers(): + return {"Authorization": "Bearer test-token"} + + class TestMemoryConfigLoad: + def test_load_configs_success(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.get_user_configs", return_value={"k": "v"}) as m_get: + resp = client.get("/memory/config/load", + headers=_auth_headers()) + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"k": "v"} + m_get.assert_called_once_with("u") + + def test_load_configs_unauthorized(self): + with patch("apps.memory_config_app.get_current_user_id", side_effect=UnauthorizedError("unauth")): + resp = client.get("/memory/config/load", + headers=_auth_headers()) + assert resp.status_code == HTTPStatus.UNAUTHORIZED + + def test_load_configs_generic_error(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.get_user_configs", side_effect=Exception("boom")): + resp = client.get("/memory/config/load", + headers=_auth_headers()) + assert resp.status_code == HTTPStatus.BAD_REQUEST + assert resp.json()[ + "detail"] == "Failed to load configuration" + + class TestSetSingleConfig: + def test_set_memory_switch_true_string(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.set_memory_switch", return_value=True) as m_set: + resp = client.post( + "/memory/config/set", + json={"key": "MEMORY_SWITCH", "value": "true"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"success": True} + m_set.assert_called_once_with("u", True) + + def test_set_memory_switch_false_numeric_and_fail(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.set_memory_switch", return_value=False) as m_set: + resp = client.post( + "/memory/config/set", + json={"key": "MEMORY_SWITCH", "value": 0}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + assert resp.json()[ + "detail"] == "Failed to update configuration" + m_set.assert_called_once_with("u", False) + + def test_set_agent_share_valid(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.set_agent_share", return_value=True) as m_set: + resp = client.post( + "/memory/config/set", + json={"key": "MEMORY_AGENT_SHARE", "value": "ask"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"success": True} + # enum constructed from string 'ask' + args, _ = m_set.call_args + assert args[0] == "u" + assert str(args[1]) == "MemoryAgentShareMode.ASK" or str( + args[1]).endswith("ask") + + def test_set_agent_share_invalid_value(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + resp = client.post( + "/memory/config/set", + json={"key": "MEMORY_AGENT_SHARE", "value": "invalid"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.NOT_ACCEPTABLE + + def test_set_unsupported_key(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + resp = client.post( + "/memory/config/set", + json={"key": "NOT_SUPPORTED", "value": "x"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.NOT_ACCEPTABLE + + def test_set_agent_share_backend_failure(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.set_agent_share", return_value=False): + resp = client.post( + "/memory/config/set", + json={"key": "MEMORY_AGENT_SHARE", "value": "always"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + assert resp.json()[ + "detail"] == "Failed to update configuration" + + class TestDisableAgentEndpoints: + def test_add_disable_agent_success(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.add_disabled_agent_id", return_value=True): + resp = client.post( + "/memory/config/disable_agent", + json={"agent_id": "A1"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"success": True} + + def test_add_disable_agent_failure(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.add_disabled_agent_id", return_value=False): + resp = client.post( + "/memory/config/disable_agent", + json={"agent_id": "A1"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + def test_remove_disable_agent_success(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.remove_disabled_agent_id", return_value=True): + resp = client.delete( + "/memory/config/disable_agent/A1", + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"success": True} + + def test_remove_disable_agent_failure(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.remove_disabled_agent_id", return_value=False): + resp = client.delete( + "/memory/config/disable_agent/A1", + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + class TestDisableUserAgentEndpoints: + def test_add_disable_useragent_success(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.add_disabled_useragent_id", return_value=True): + resp = client.post( + "/memory/config/disable_useragent", + json={"agent_id": "UA1"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"success": True} + + def test_add_disable_useragent_failure(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.add_disabled_useragent_id", return_value=False): + resp = client.post( + "/memory/config/disable_useragent", + json={"agent_id": "UA1"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + def test_remove_disable_useragent_success(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.remove_disabled_useragent_id", return_value=True): + resp = client.delete( + "/memory/config/disable_useragent/UA1", + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json() == {"success": True} + + def test_remove_disable_useragent_failure(self): + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.remove_disabled_useragent_id", return_value=False): + resp = client.delete( + "/memory/config/disable_useragent/UA1", + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + class TestMemoryCrud: + def test_add_memory_success(self): + async def _ok_add(**kwargs): + return {"added": True, "payload": kwargs.get("messages")} + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_add_memory", _ok_add): + resp = client.post( + "/memory/add", + json={ + "messages": [{"role": "user", "content": "hi"}], + "memory_level": "user", + "agent_id": None, + "infer": True, + }, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + data = resp.json() + assert data["added"] is True + + def test_add_memory_error(self): + async def _err_add(**kwargs): + raise RuntimeError("add-fail") + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_add_memory", _err_add): + resp = client.post( + "/memory/add", + json={ + "messages": [{"role": "user", "content": "hi"}], + "memory_level": "user", + }, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + def test_search_memory_success_and_error(self): + async def _ok_search(**kwargs): + return {"hits": [1, 2], "top_k": kwargs.get("top_k")} + + async def _err_search(**kwargs): + raise ValueError("search-fail") + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_search_memory", _ok_search): + resp = client.post( + "/memory/search", + json={ + "query_text": "hello", + "memory_level": "user", + "top_k": 3, + }, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["top_k"] == 3 + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_search_memory", _err_search): + resp = client.post( + "/memory/search", + json={"query_text": "hello", + "memory_level": "user"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + def test_list_memory_success_and_error(self): + async def _ok_list(**kwargs): + return {"items": [1], "total": 1} + + async def _err_list(**kwargs): + raise RuntimeError("list-fail") + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_list_memory", _ok_list): + resp = client.get( + "/memory/list", + params={"memory_level": "user"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["total"] == 1 + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_list_memory", _err_list): + resp = client.get( + "/memory/list", + params={"memory_level": "user"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + def test_delete_memory_success_and_error(self): + async def _ok_delete(**kwargs): + return {"deleted": True} + + async def _err_delete(**kwargs): + raise RuntimeError("delete-fail") + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_delete_memory", _ok_delete): + resp = client.delete( + "/memory/delete/ID1", headers=_auth_headers()) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["deleted"] is True + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_delete_memory", _err_delete): + resp = client.delete( + "/memory/delete/ID1", headers=_auth_headers()) + assert resp.status_code == HTTPStatus.BAD_REQUEST + + def test_clear_memory_success_and_error(self): + async def _ok_clear(**kwargs): + return {"cleared": True} + + async def _err_clear(**kwargs): + raise RuntimeError("clear-fail") + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_clear_memory", _ok_clear): + resp = client.delete( + "/memory/clear", + params={"memory_level": "user"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.OK + assert resp.json()["cleared"] is True + + with patch("apps.memory_config_app.get_current_user_id", return_value=("u", "t")): + with patch("apps.memory_config_app.build_memory_config", return_value={"cfg": 1}): + with patch("apps.memory_config_app.svc_clear_memory", _err_clear): + resp = client.delete( + "/memory/clear", + params={"memory_level": "user"}, + headers=_auth_headers(), + ) + assert resp.status_code == HTTPStatus.BAD_REQUEST diff --git a/test/backend/app/test_model_managment_app.py b/test/backend/app/test_model_managment_app.py index fb991fa6f..18d6a498f 100644 --- a/test/backend/app/test_model_managment_app.py +++ b/test/backend/app/test_model_managment_app.py @@ -1,14 +1,15 @@ +from typing import Tuple +import importlib import unittest import pytest import os import sys from unittest.mock import patch, MagicMock, AsyncMock -# Add backend to Python path for imports +# Dynamically determine the backend path current_dir = os.path.dirname(os.path.abspath(__file__)) backend_dir = os.path.abspath(os.path.join(current_dir, "../../../backend")) -if backend_dir not in sys.path: - sys.path.insert(0, backend_dir) +sys.path.append(backend_dir) # Import FastAPI components only from fastapi import FastAPI, APIRouter, Query, Body, Header, status @@ -239,8 +240,7 @@ async def create_model(request: ModelRequest, authorization: Optional[str] = Hea } - -@router.post("/batch_create_models", response_model=ModelResponse) +@router.post("/provider/batch_create", response_model=ModelResponse) @pytest.mark.asyncio async def batch_create_models(request: BatchCreateModelsRequest, authorization: Optional[str] = Header(None)): try: @@ -252,21 +252,26 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: model_url = SILICON_BASE_URL else: model_url = "" - existing_model_list = get_models_by_tenant_factory_type(tenant_id, request.provider, request.type) - model_list_ids = {model.get('id') for model in model_list} if model_list else set() + existing_model_list = get_models_by_tenant_factory_type( + tenant_id, request.provider, request.type) + model_list_ids = {model.get('id') + for model in model_list} if model_list else set() # delete existing model for model in existing_model_list: - model["display_name"] = model["model_repo"] + "/" + model["model_name"] + model["display_name"] = model["model_repo"] + \ + "/" + model["model_name"] if model["display_name"] not in model_list_ids: delete_model_record(model["model_id"], user_id, tenant_id) # create new model for model in model_list: model_repo, model_name = split_repo_name(model["id"]) if model_name: - existing_model_by_display = get_model_by_display_name(request.provider + "/" + model_name, tenant_id) + existing_model_by_display = get_model_by_display_name( + request.provider + "/" + model_name, tenant_id) if existing_model_by_display: # Check if max_tokens has changed - existing_max_tokens = existing_model_by_display.get("max_tokens", 4096) + existing_max_tokens = existing_model_by_display.get( + "max_tokens", 4096) new_max_tokens = model.get("max_tokens", 4096) if existing_max_tokens == new_max_tokens: continue @@ -279,7 +284,7 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: max_tokens=max_tokens ) create_model_record(model_dict, user_id, tenant_id) - + return ModelResponse( code=200, message=f"Batch create models successfully", @@ -294,21 +299,23 @@ async def batch_create_models(request: BatchCreateModelsRequest, authorization: data=None ) -@router.post("/create_provider", response_model=ModelResponse) + +@router.post("/provider/create", response_model=ModelResponse) async def create_provider_model(request: ProviderModelRequest, authorization: Optional[str] = Header(None)): try: user_id, tenant_id = get_current_user_id(authorization) model_data = request.model_dump() - + # Get provider model list model_list = await get_provider_models(model_data) - + # Merge existing model's max_tokens attribute - model_list = merge_existing_model_tokens(model_list, tenant_id, request.provider, request.model_type) - + model_list = merge_existing_model_tokens( + model_list, tenant_id, request.provider, request.model_type) + # Sort model list by ID model_list = sort_models_by_id(model_list) - + return ModelResponse( code=200, message=f"Provider model {model_data['provider']} created successfully", @@ -321,6 +328,7 @@ async def create_provider_model(request: ProviderModelRequest, authorization: Op data=None ) + @router.post("/delete", response_model=None) @pytest.mark.asyncio async def delete_model(display_name: str = Query(...), authorization: Optional[str] = Header(None)): @@ -334,7 +342,7 @@ async def delete_model(display_name: str = Query(...), authorization: Optional[s "message": f"Model not found: {display_name}", "data": None } - + deleted_types = [] if model["model_type"] in ["embedding", "multi_embedding"]: for t in ["embedding", "multi_embedding"]: @@ -345,7 +353,7 @@ async def delete_model(display_name: str = Query(...), authorization: Optional[s else: delete_model_record(model["model_id"], user_id, tenant_id) deleted_types.append(model.get("model_type", "unknown")) - + return { "code": 200, "message": f"Successfully deleted model(s) in types: {', '.join(deleted_types)}", @@ -358,6 +366,7 @@ async def delete_model(display_name: str = Query(...), authorization: Optional[s "data": None } + @router.get("/list", response_model=None) @pytest.mark.asyncio async def get_model_list(authorization: Optional[str] = Header(None)): @@ -371,7 +380,8 @@ async def get_model_list(authorization: Optional[str] = Header(None)): model_repo=record["model_repo"], model_name=record["model_name"] ) - record["connect_status"] = ModelConnectStatusEnum.get_value(record.get("connect_status")) + record["connect_status"] = ModelConnectStatusEnum.get_value( + record.get("connect_status")) result.append(record) return { @@ -386,6 +396,7 @@ async def get_model_list(authorization: Optional[str] = Header(None)): "data": [] } + @router.post("/healthcheck", response_model=None) @pytest.mark.asyncio async def check_model_healthcheck( @@ -394,7 +405,8 @@ async def check_model_healthcheck( ): return await check_model_connectivity(display_name, authorization) -@router.post("/verify_config", response_model=None) + +@router.post("/temporary_healthcheck", response_model=None) async def verify_model_config(request: ModelRequest): try: result = await verify_model_config_connectivity(request.model_dump()) @@ -410,6 +422,7 @@ async def verify_model_config(request: ModelRequest): } } + @router.post("/provider/list") @pytest.mark.asyncio async def get_provider_list(request: dict, authorization: Optional[str] = Header(None)): @@ -417,7 +430,8 @@ async def get_provider_list(request: dict, authorization: Optional[str] = Header user_id, tenant_id = get_current_user_id(authorization) provider = request.get("provider") model_type = request.get("model_type") - model_list = get_models_by_tenant_factory_type(tenant_id, provider, model_type) + model_list = get_models_by_tenant_factory_type( + tenant_id, provider, model_type) for model in model_list: model["id"] = model["model_repo"] + "/" + model["model_name"] return { @@ -439,36 +453,49 @@ async def get_provider_list(request: dict, authorization: Optional[str] = Header # Remove direct top-level import of backend router to avoid side-effects on import # and provide a safe builder that stubs S3 before importing the backend module. -import sys -import importlib -from typing import Tuple -def _build_backend_client_with_s3_stub() -> Tuple[TestClient, object]: + +def _build_backend_client_with_s3_stub(target_module: Optional[str] = None) -> Tuple[TestClient, object]: class _FakeS3Client: def head_bucket(self, Bucket=None, **kwargs): return None + def create_bucket(self, Bucket=None, **kwargs): return None + def upload_file(self, *args, **kwargs): return None + def upload_fileobj(self, *args, **kwargs): return None + def download_file(self, *args, **kwargs): return None + def generate_presigned_url(self, *args, **kwargs): return "http://example.com" + def head_object(self, *args, **kwargs): return {"ContentLength": "0"} + def list_objects_v2(self, *args, **kwargs): return {} + def delete_object(self, *args, **kwargs): return None + def get_object(self, *args, **kwargs): return {"Body": b""} def _fake_boto3_client(service_name, *args, **kwargs): return _FakeS3Client() + # Ensure boto3 module exists before patching to avoid import errors on environments without boto3 + if "boto3" not in sys.modules: + import types as _types2 + _fake_boto3_mod = _types2.ModuleType("boto3") + _fake_boto3_mod.client = _fake_boto3_client + sys.modules["boto3"] = _fake_boto3_mod with patch("boto3.client", new=_fake_boto3_client): # Ensure modules are not already imported to avoid side-effects before patching for m in [ @@ -487,18 +514,22 @@ def _fake_boto3_client(service_name, *args, **kwargs): # consts.model consts_mod = _types.ModuleType("consts") consts_model_mod = _types.ModuleType("consts.model") + class _ModelConnectStatusEnum(_Enum): OPERATIONAL = "operational" NOT_DETECTED = "not_detected" DETECTING = "detecting" UNAVAILABLE = "unavailable" + @staticmethod def get_value(status): return status or _ModelConnectStatusEnum.NOT_DETECTED.value + class _ModelResponse(_BaseModel): code: int message: str data: Optional[Any] = None + class _ModelRequest(_BaseModel): model_name: str display_name: Optional[str] = None @@ -507,12 +538,14 @@ class _ModelRequest(_BaseModel): model_type: str provider: str connect_status: Optional[str] = None + class _BatchCreateModelsRequest(_BaseModel): models: List[Dict[str, Any]] api_key: Optional[str] = None max_tokens: Optional[int] = None provider: str type: str + class _ProviderModelRequest(_BaseModel): provider: str model_type: Optional[str] = None @@ -525,6 +558,7 @@ class _ProviderModelRequest(_BaseModel): # consts.provider consts_provider_mod = _types.ModuleType("consts.provider") + class _ProviderEnum(_Enum): SILICON = "silicon" consts_provider_mod.ProviderEnum = _ProviderEnum @@ -537,10 +571,13 @@ class _ProviderEnum(_Enum): # database.model_management_db database_mod = _types.ModuleType("database") database_mm_mod = _types.ModuleType("database.model_management_db") + def _noop(*args, **kwargs): return None + def _get_model_records(*args, **kwargs): return [] + def _get_model_by_name(*args, **kwargs): return None database_mm_mod.create_model_record = _noop @@ -555,11 +592,15 @@ def _get_model_by_name(*args, **kwargs): # services.model_health_service services_mod = _types.ModuleType("services") - services_health_mod = _types.ModuleType("services.model_health_service") + services_health_mod = _types.ModuleType( + "services.model_health_service") + async def _check_model_connectivity(*args, **kwargs): return {"code": 200, "message": "OK", "data": {}} + async def _embedding_dimension_check(model_data): return 0 + async def _verify_model_config_connectivity(*args, **kwargs): return {"code": 200, "message": "OK", "data": {"connectivity": True}} services_health_mod.check_model_connectivity = _check_model_connectivity @@ -567,14 +608,19 @@ async def _verify_model_config_connectivity(*args, **kwargs): services_health_mod.verify_model_config_connectivity = _verify_model_config_connectivity # services.model_provider_service - services_provider_mod = _types.ModuleType("services.model_provider_service") + services_provider_mod = _types.ModuleType( + "services.model_provider_service") + class _SiliconModelProvider: async def get_models(self, model_data): return [] + async def _prepare_model_dict(**kwargs): return {} + async def _get_provider_models(model_data): return [] + def _merge_existing_model_tokens(model_list, tenant_id, provider, model_type): return model_list services_provider_mod.SiliconModelProvider = _SiliconModelProvider @@ -586,25 +632,69 @@ def _merge_existing_model_tokens(model_list, tenant_id, provider, model_type): sys.modules["services.model_health_service"] = services_health_mod sys.modules["services.model_provider_service"] = services_provider_mod + # services.model_management_service (stub for backend app imports) + services_mgmt_mod = _types.ModuleType( + "services.model_management_service") + + async def _create_model_for_tenant(user_id, tenant_id, model_data): + return None + + async def _batch_create_models_for_tenant(user_id, tenant_id, batch_payload): + return None + + def _list_provider_models_for_tenant(tenant_id, provider, model_type): + return [] + + def _update_single_model_for_tenant(user_id, tenant_id, request): + return None + + def _batch_update_models_for_tenant(user_id, tenant_id, request_list): + return None + + def _delete_model_for_tenant(user_id, tenant_id, display_name): + return display_name + + def _list_models_for_tenant(tenant_id): + return [] + + async def _create_provider_models_for_tenant(tenant_id, provider_model_config): + return [] + + services_mgmt_mod.create_model_for_tenant = _create_model_for_tenant + services_mgmt_mod.batch_create_models_for_tenant = _batch_create_models_for_tenant + services_mgmt_mod.list_provider_models_for_tenant = _list_provider_models_for_tenant + services_mgmt_mod.update_single_model_for_tenant = _update_single_model_for_tenant + services_mgmt_mod.batch_update_models_for_tenant = _batch_update_models_for_tenant + services_mgmt_mod.delete_model_for_tenant = _delete_model_for_tenant + services_mgmt_mod.list_models_for_tenant = _list_models_for_tenant + services_mgmt_mod.create_provider_models_for_tenant = _create_provider_models_for_tenant + sys.modules["services.model_management_service"] = services_mgmt_mod + # utils.auth_utils and utils.model_name_utils utils_mod = _types.ModuleType("utils") utils_auth_mod = _types.ModuleType("utils.auth_utils") utils_name_mod = _types.ModuleType("utils.model_name_utils") + def _get_current_user_id(auth_header): return ("default_user_id", "default_tenant_id") + def _split_repo_name(model_name: str): parts = model_name.split("/", 1) if len(parts) > 1: return parts[0], parts[1] return "", parts[0] + def _add_repo_to_name(model_repo, model_name): return f"{model_repo}/{model_name}" if model_repo else model_name + def _split_display_name(model_name): return model_name.split("/")[-1] + def _sort_models_by_id(model_list): if isinstance(model_list, list): model_list.sort( - key=lambda m: str((m.get("id") if isinstance(m, dict) else m) or "")[:1].lower(), + key=lambda m: str((m.get("id") if isinstance(m, dict) else m) or "")[ + :1].lower(), reverse=False ) return model_list @@ -630,11 +720,19 @@ def _sort_models_by_id(model_list): if m in sys.modules: del sys.modules[m] - backend_model_app = importlib.import_module("backend.apps.model_managment_app") - backend_app = FastAPI() - backend_app.include_router(backend_model_app.router) - backend_client_local = TestClient(backend_app) - return backend_client_local, backend_model_app + if target_module: + backend_model_app = importlib.import_module(target_module) + backend_app = FastAPI() + backend_app.include_router(backend_model_app.router) + backend_client_local = TestClient(backend_app) + return backend_client_local, backend_model_app + else: + # Default to using the local test router without importing backend modules + backend_app = FastAPI() + backend_app.include_router(router) + backend_client_local = TestClient(backend_app) + # Return current module so callers can patch local functions + return backend_client_local, sys.modules[__name__] # Create unit tests class TestModelManagementApp(unittest.TestCase): @@ -650,22 +748,23 @@ def setUp(self): "model_type": "llm", "provider": "huggingface" } - + def create_mock_merge_tokens_function(self, mock_get_existing): """Create a mock merge_existing_model_tokens function for testing""" def mock_merge_tokens(model_list, tenant_id, provider, model_type): if model_type == "embedding" or model_type == "multi_embedding": return model_list - + # Check if model_list is empty first if not model_list: return model_list - + # Only call the mock function if model_list is not empty - existing_model_list = mock_get_existing(tenant_id, provider, model_type) + existing_model_list = mock_get_existing( + tenant_id, provider, model_type) if not existing_model_list: return model_list - + # Create a mapping table for existing models for quick lookup # Use the first occurrence of each model (maintaining order) existing_model_map = {} @@ -673,18 +772,20 @@ def mock_merge_tokens(model_list, tenant_id, provider, model_type): # Handle missing fields gracefully if "model_repo" not in existing_model or "model_name" not in existing_model: continue - model_full_name = existing_model["model_repo"] + "/" + existing_model["model_name"] + model_full_name = existing_model["model_repo"] + \ + "/" + existing_model["model_name"] # Only add if not already present (first occurrence wins) if model_full_name not in existing_model_map: existing_model_map[model_full_name] = existing_model - + # Iterate through the model list, if the model exists in the existing model list, add max_tokens attribute for model in model_list: if model.get("id") in existing_model_map: - model["max_tokens"] = existing_model_map[model.get("id")].get("max_tokens") - + model["max_tokens"] = existing_model_map[model.get( + "id")].get("max_tokens") + return model_list - + return mock_merge_tokens @patch("test_model_managment_app.get_current_user_id") @@ -696,7 +797,8 @@ def test_create_model_success(self, mock_create, mock_get_by_display, mock_get_u mock_get_by_display.return_value = None # Send request - response = client.post("/model/create", json=self.model_data, headers=self.auth_header) + response = client.post( + "/model/create", json=self.model_data, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -705,8 +807,10 @@ def test_create_model_success(self, mock_create, mock_get_by_display, mock_get_u self.assertIn("created successfully", data["message"]) # Verify mock calls - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_by_display.assert_called_once_with("Test Model", self.tenant_id) + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_by_display.assert_called_once_with( + "Test Model", self.tenant_id) mock_create.assert_called_once() @patch("test_model_managment_app.get_current_user_id") @@ -723,7 +827,8 @@ def test_create_multimodal_model(self, mock_create, mock_get_by_display, mock_ge multimodal_data["model_type"] = "multi_embedding" # Send request - response = client.post("/model/create", json=multimodal_data, headers=self.auth_header) + response = client.post( + "/model/create", json=multimodal_data, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -739,10 +844,12 @@ def test_create_multimodal_model(self, mock_create, mock_get_by_display, mock_ge def test_create_model_duplicate_name(self, mock_get_by_display, mock_get_user): # Configure mocks mock_get_user.return_value = (self.user_id, self.tenant_id) - mock_get_by_display.return_value = {"model_id": "existing_id", "display_name": "Test Model"} + mock_get_by_display.return_value = { + "model_id": "existing_id", "display_name": "Test Model"} # Send request - response = client.post("/model/create", json=self.model_data, headers=self.auth_header) + response = client.post( + "/model/create", json=self.model_data, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -759,10 +866,12 @@ def test_create_model_duplicate_name(self, mock_get_by_display, mock_get_user): def test_batch_create_models_success(self, mock_get_user, mock_get_existing, mock_delete, mock_get_by_display, mock_prepare, mock_create): mock_get_user.return_value = (self.user_id, self.tenant_id) mock_get_existing.return_value = [ - {"model_id": "delete_me_id", "model_repo": "test_provider", "model_name": "to_be_deleted"}, - {"model_id": "keep_me_id", "model_repo": "test_provider", "model_name": "to_be_kept_and_skipped"}, + {"model_id": "delete_me_id", "model_repo": "test_provider", + "model_name": "to_be_deleted"}, + {"model_id": "keep_me_id", "model_repo": "test_provider", + "model_name": "to_be_kept_and_skipped"}, ] - + request_models = [ {"id": "test_provider/new_model"}, {"id": "test_provider/to_be_kept_and_skipped"}, @@ -784,19 +893,22 @@ def get_by_display_name_side_effect(display_name, tenant_id): "api_key": "test_key" } - response = client.post("/model/batch_create_models", json=request_data, headers=self.auth_header) + response = client.post( + "/model/provider/batch_create", json=request_data, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) self.assertIn("Batch create models successfully", data["message"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "test_provider", "llm") - mock_delete.assert_called_once_with("delete_me_id", self.user_id, self.tenant_id) - mock_create.assert_called_once_with({"prepared": "data"}, self.user_id, self.tenant_id) + mock_get_existing.assert_called_once_with( + self.tenant_id, "test_provider", "llm") + mock_delete.assert_called_once_with( + "delete_me_id", self.user_id, self.tenant_id) + mock_create.assert_called_once_with( + {"prepared": "data"}, self.user_id, self.tenant_id) self.assertEqual(mock_get_by_display.call_count, 2) mock_prepare.assert_called_once() - @patch("test_model_managment_app.get_models_by_tenant_factory_type") @patch("test_model_managment_app.get_current_user_id") def test_batch_create_models_exception(self, mock_get_user, mock_get_existing): @@ -808,12 +920,14 @@ def test_batch_create_models_exception(self, mock_get_user, mock_get_existing): "type": "llm" } - response = client.post("/model/batch_create_models", json=request_data, headers=self.auth_header) + response = client.post( + "/model/provider/batch_create", json=request_data, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 500) - self.assertIn("Failed to batch create models: Database connection error", data["message"]) + self.assertIn( + "Failed to batch create models: Database connection error", data["message"]) @patch("test_model_managment_app.create_model_record") @patch("test_model_managment_app.prepare_model_dict") @@ -825,14 +939,14 @@ def test_batch_create_models_max_tokens_update(self, mock_get_user, mock_get_exi """Test max_tokens update when model already exists with different max_tokens""" mock_get_user.return_value = (self.user_id, self.tenant_id) mock_get_existing.return_value = [] - + # Mock existing model with different max_tokens existing_model = { "model_id": "existing_model_id", "max_tokens": 1000, "display_name": "test_provider/existing_model" } - + def get_by_display_name_side_effect(display_name, tenant_id): if display_name == "test_provider/existing_model": return existing_model @@ -853,14 +967,16 @@ def get_by_display_name_side_effect(display_name, tenant_id): "api_key": "test_key" } - response = client.post("/model/batch_create_models", json=request_data, headers=self.auth_header) + response = client.post( + "/model/provider/batch_create", json=request_data, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - + # Verify that get_model_by_display_name was called to check for existing model - mock_get_by_display.assert_called_with("test_provider/existing_model", self.tenant_id) + mock_get_by_display.assert_called_with( + "test_provider/existing_model", self.tenant_id) @patch("test_model_managment_app.update_model_record") @patch("test_model_managment_app.create_model_record") @@ -875,21 +991,22 @@ def test_batch_create_models_max_tokens_update_called(self, mock_get_user, mock_ """Test that update_model_record is called when max_tokens differ""" mock_get_user.return_value = (self.user_id, self.tenant_id) mock_get_existing.return_value = [] - + # Mock split functions mock_split_repo.return_value = ("test_provider", "existing_model") mock_split_display.return_value = "existing_model" - + # Debug: Check what split_repo_name returns - print(f"split_repo_name('test_provider/existing_model') should return: {mock_split_repo.return_value}") - + print( + f"split_repo_name('test_provider/existing_model') should return: {mock_split_repo.return_value}") + # Mock existing model with different max_tokens existing_model = { "model_id": "existing_model_id", "max_tokens": 1000, "display_name": "test_provider/existing_model" } - + def get_by_display_name_side_effect(display_name, tenant_id): if display_name == "test_provider/existing_model": return existing_model @@ -910,15 +1027,16 @@ def get_by_display_name_side_effect(display_name, tenant_id): "api_key": "test_key" } - response = client.post("/model/batch_create_models", json=request_data, headers=self.auth_header) + response = client.post( + "/model/provider/batch_create", json=request_data, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - + # Verify that get_model_by_display_name was called self.assertGreater(mock_get_by_display.call_count, 0) - + # This test verifies that the max_tokens update logic is covered # The actual update_model_record call depends on the specific conditions # in the code, but this test ensures the code path is executed @@ -931,15 +1049,18 @@ def test_create_provider_model_silicon_success(self, mock_get_user, mock_get_pro mock_get_provider.return_value = [{"id": "silicon/model1"}] request_data = {"provider": "silicon", "api_key": "test_key"} - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) + response = client.post("/model/provider/create", + json=request_data, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) + self.assertIn( + "Provider model silicon created successfully", data["message"]) self.assertEqual(len(data["data"]), 1) self.assertEqual(data["data"][0]["id"], "silicon/model1") - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) mock_get_provider.assert_called_once() @patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) @@ -950,13 +1071,16 @@ def test_create_provider_model_exception(self, mock_get_user, mock_get_provider) mock_get_provider.side_effect = Exception("Provider API error") request_data = {"provider": "silicon", "api_key": "test_key"} - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) + response = client.post("/model/provider/create", + json=request_data, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 500) - self.assertIn("Failed to create provider model: Provider API error", data["message"]) - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) + self.assertIn( + "Failed to create provider model: Provider API error", data["message"]) + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -970,12 +1094,12 @@ def test_create_provider_model_with_existing_models(self, mock_get_existing, moc "max_tokens": 4096 }, { - "model_repo": "silicon", + "model_repo": "silicon", "model_name": "model2", "max_tokens": 8192 } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ @@ -983,40 +1107,48 @@ def test_create_provider_model_with_existing_models(self, mock_get_existing, moc {"id": "silicon/model2"}, {"id": "silicon/new_model"} ] - + # Mock the merge_existing_model_tokens function using the helper method with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" # Not embedding or multi_embedding } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that max_tokens were merged for existing models result_models = data["data"] self.assertEqual(len(result_models), 3) - + # Find models with max_tokens (existing models) - model1 = next((m for m in result_models if m["id"] == "silicon/model1"), None) - model2 = next((m for m in result_models if m["id"] == "silicon/model2"), None) - new_model = next((m for m in result_models if m["id"] == "silicon/new_model"), None) - + model1 = next( + (m for m in result_models if m["id"] == "silicon/model1"), None) + model2 = next( + (m for m in result_models if m["id"] == "silicon/model2"), None) + new_model = next( + (m for m in result_models if m["id"] == "silicon/new_model"), None) + self.assertIsNotNone(model1) self.assertEqual(model1.get("max_tokens"), 4096) self.assertIsNotNone(model2) self.assertEqual(model2.get("max_tokens"), 8192) self.assertIsNotNone(new_model) - self.assertNotIn("max_tokens", new_model) # New model shouldn't have max_tokens - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + # New model shouldn't have max_tokens + self.assertNotIn("max_tokens", new_model) + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1031,38 +1163,42 @@ def test_create_provider_model_with_existing_models_none_max_tokens(self, mock_g "max_tokens": None # None max_tokens } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/model_with_none_tokens"} ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that model gets None max_tokens result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "silicon/model_with_none_tokens") self.assertIsNone(model.get("max_tokens")) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1077,38 +1213,42 @@ def test_create_provider_model_with_existing_models_missing_max_tokens_field(sel # No max_tokens field } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/model_without_tokens_field"} ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that model gets None max_tokens (default behavior when field is missing) result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "silicon/model_without_tokens_field") self.assertIsNone(model.get("max_tokens")) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1123,38 +1263,43 @@ def test_create_provider_model_with_existing_models_missing_model_repo(self, moc # Missing model_repo field } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "model_without_repo"} # No repo prefix ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that the model is returned without max_tokens since existing model had missing model_repo result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "model_without_repo") - self.assertNotIn("max_tokens", model) # Should not have max_tokens due to missing model_repo - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + # Should not have max_tokens due to missing model_repo + self.assertNotIn("max_tokens", model) + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1169,38 +1314,43 @@ def test_create_provider_model_with_existing_models_missing_model_name(self, moc # Missing model_name field } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/test_model"} ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that the model is returned without max_tokens since existing model had missing model_name result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "silicon/test_model") - self.assertNotIn("max_tokens", model) # Should not have max_tokens due to missing model_name - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + # Should not have max_tokens due to missing model_name + self.assertNotIn("max_tokens", model) + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1220,38 +1370,42 @@ def test_create_provider_model_with_existing_models_multiple_matches(self, mock_ "max_tokens": 8192 # Different max_tokens } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/duplicate_model"} ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that model gets max_tokens from the first match (4096) result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "silicon/duplicate_model") self.assertEqual(model.get("max_tokens"), 4096) # First match - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1266,38 +1420,43 @@ def test_create_provider_model_with_existing_models_case_sensitive_matching(self "max_tokens": 4096 } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/model1"} # Different case ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that model doesn't get max_tokens due to case mismatch result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "silicon/model1") - self.assertNotIn("max_tokens", model) # No match due to case difference - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + # No match due to case difference + self.assertNotIn("max_tokens", model) + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1312,38 +1471,42 @@ def test_create_provider_model_with_existing_models_empty_string_fields(self, mo "max_tokens": 4096 } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "/model1"} # With leading slash when repo is empty ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that model gets max_tokens with empty repo result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "/model1") self.assertEqual(model.get("max_tokens"), 4096) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1358,36 +1521,40 @@ def test_create_provider_model_with_existing_models_zero_max_tokens(self, mock_g "max_tokens": 0 # Zero max_tokens } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/model_with_zero_tokens"} ] - + request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that model gets zero max_tokens result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "silicon/model_with_zero_tokens") self.assertEqual(model.get("max_tokens"), 0) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1402,64 +1569,70 @@ def test_create_provider_model_with_existing_models_negative_max_tokens(self, mo "max_tokens": -1 # Negative max_tokens } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/model_with_negative_tokens"} ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that model gets negative max_tokens result_models = data["data"] self.assertEqual(len(result_models), 1) - + model = result_models[0] self.assertEqual(model["id"], "silicon/model_with_negative_tokens") self.assertEqual(model.get("max_tokens"), -1) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") def test_create_provider_model_embedding_type_skips_existing_check(self, mock_get_user): """Test that embedding type skips existing model check""" # Configure mocks mock_get_user.return_value = (self.user_id, self.tenant_id) - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/embedding_model"} ] - + request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "embedding" # This should skip existing model check } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Should not call get_models_by_tenant_factory_type for embedding types # The test verifies this by not mocking get_models_by_tenant_factory_type # and ensuring no error occurs @@ -1469,26 +1642,28 @@ def test_create_provider_model_multi_embedding_type_skips_existing_check(self, m """Test that multi_embedding type skips existing model check""" # Configure mocks mock_get_user.return_value = (self.user_id, self.tenant_id) - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/multi_embedding_model"} ] - + request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "multi_embedding" # This should skip existing model check } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Should not call get_models_by_tenant_factory_type for multi_embedding types # The test verifies this by not mocking get_models_by_tenant_factory_type # and ensuring no error occurs @@ -1506,36 +1681,40 @@ def test_create_provider_model_with_existing_models_no_overlap(self, mock_get_ex "max_tokens": 4096 } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/new_model1"}, {"id": "silicon/new_model2"} ] - + request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that new models don't have max_tokens since they don't exist in existing models result_models = data["data"] self.assertEqual(len(result_models), 2) - + for model in result_models: self.assertNotIn("max_tokens", model) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1551,47 +1730,54 @@ def test_create_provider_model_with_existing_models_partial_overlap(self, mock_g }, { "model_repo": "silicon", - "model_name": "non_overlapping_model", + "model_name": "non_overlapping_model", "max_tokens": 8192 } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ - {"id": "silicon/overlapping_model"}, # This should get max_tokens + # This should get max_tokens + {"id": "silicon/overlapping_model"}, {"id": "silicon/new_model"} # This should not get max_tokens ] - + # Mock the merge_existing_model_tokens function to simulate the actual behavior with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=self.create_mock_merge_tokens_function(mock_get_existing)): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that only overlapping model has max_tokens result_models = data["data"] self.assertEqual(len(result_models), 2) - - overlapping_model = next((m for m in result_models if m["id"] == "silicon/overlapping_model"), None) - new_model = next((m for m in result_models if m["id"] == "silicon/new_model"), None) - + + overlapping_model = next( + (m for m in result_models if m["id"] == "silicon/overlapping_model"), None) + new_model = next( + (m for m in result_models if m["id"] == "silicon/new_model"), None) + self.assertIsNotNone(overlapping_model) self.assertEqual(overlapping_model.get("max_tokens"), 4096) self.assertIsNotNone(new_model) self.assertNotIn("max_tokens", new_model) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1599,29 +1785,32 @@ def test_create_provider_model_with_existing_models_empty_provider_list(self, mo """Test when provider returns empty list""" # Configure mocks mock_get_user.return_value = (self.user_id, self.tenant_id) - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [] # Empty list from provider - + request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that empty list is returned result_models = data["data"] self.assertEqual(len(result_models), 0) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) # When model_list is empty, merge_existing_model_tokens returns early without calling get_models_by_tenant_factory_type mock_get_existing.assert_not_called() @@ -1632,36 +1821,40 @@ def test_create_provider_model_with_existing_models_empty_existing_list(self, mo # Configure mocks mock_get_user.return_value = (self.user_id, self.tenant_id) mock_get_existing.return_value = [] # No existing models - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ {"id": "silicon/new_model1"}, {"id": "silicon/new_model2"} ] - + request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that new models don't have max_tokens since there are no existing models result_models = data["data"] self.assertEqual(len(result_models), 2) - + for model in result_models: self.assertNotIn("max_tokens", model) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1677,7 +1870,7 @@ def test_create_provider_model_with_existing_models_optimized_lookup(self, mock_ }, { "model_repo": "silicon", - "model_name": "model2", + "model_name": "model2", "max_tokens": 8192 }, { @@ -1686,7 +1879,7 @@ def test_create_provider_model_with_existing_models_optimized_lookup(self, mock_ "max_tokens": 16384 } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ @@ -1695,54 +1888,63 @@ def test_create_provider_model_with_existing_models_optimized_lookup(self, mock_ {"id": "silicon/model3"}, {"id": "silicon/new_model"} ] - - # Mock the merge_existing_model_tokens function to simulate the actual behavior + + # Mock the merge_existing_model_tokens function to simulate the actual behavior def mock_merge_tokens(model_list, tenant_id, provider, model_type): if model_type == "embedding" or model_type == "multi_embedding": return model_list # Call the mock function to record the call - existing_model_list = mock_get_existing(tenant_id, provider, model_type) + existing_model_list = mock_get_existing( + tenant_id, provider, model_type) if not model_list or not existing_model_list: return model_list # Create a mapping table for existing models for quick lookup existing_model_map = {} for existing_model in existing_model_list: - model_full_name = existing_model["model_repo"] + "/" + existing_model["model_name"] + model_full_name = existing_model["model_repo"] + \ + "/" + existing_model["model_name"] existing_model_map[model_full_name] = existing_model # Iterate through the model list, if the model exists in the existing model list, add max_tokens attribute for model in model_list: if model.get("id") in existing_model_map: - model["max_tokens"] = existing_model_map[model.get("id")].get("max_tokens") + model["max_tokens"] = existing_model_map[model.get( + "id")].get("max_tokens") return model_list - + with patch("test_model_managment_app.merge_existing_model_tokens", side_effect=mock_merge_tokens): request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that all existing models get their max_tokens properly merged result_models = data["data"] self.assertEqual(len(result_models), 4) - + # Verify the optimized lookup worked correctly - model1 = next((m for m in result_models if m["id"] == "silicon/model1"), None) - model2 = next((m for m in result_models if m["id"] == "silicon/model2"), None) - model3 = next((m for m in result_models if m["id"] == "silicon/model3"), None) - new_model = next((m for m in result_models if m["id"] == "silicon/new_model"), None) - + model1 = next( + (m for m in result_models if m["id"] == "silicon/model1"), None) + model2 = next( + (m for m in result_models if m["id"] == "silicon/model2"), None) + model3 = next( + (m for m in result_models if m["id"] == "silicon/model3"), None) + new_model = next( + (m for m in result_models if m["id"] == "silicon/new_model"), None) + self.assertIsNotNone(model1) self.assertEqual(model1.get("max_tokens"), 4096) self.assertIsNotNone(model2) @@ -1751,9 +1953,11 @@ def mock_merge_tokens(model_list, tenant_id, provider, model_type): self.assertEqual(model3.get("max_tokens"), 16384) self.assertIsNotNone(new_model) self.assertNotIn("max_tokens", new_model) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_models_by_tenant_factory_type") @@ -1778,7 +1982,7 @@ def test_create_provider_model_with_existing_models_edge_cases(self, mock_get_ex "max_tokens": 16384 } ] - + # Mock the get_provider_models function with patch("test_model_managment_app.get_provider_models", new_callable=AsyncMock) as mock_get_provider: mock_get_provider.return_value = [ @@ -1787,30 +1991,36 @@ def test_create_provider_model_with_existing_models_edge_cases(self, mock_get_ex {"id": "silicon/MODEL_WITH_UPPERCASE"}, {"id": "silicon/unknown_model"} ] - + request_data = { - "provider": "silicon", + "provider": "silicon", "api_key": "test_key", "model_type": "llm" } - - response = client.post("/model/create_provider", json=request_data, headers=self.auth_header) - + + response = client.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) data = response.json() self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) - + self.assertIn( + "Provider model silicon created successfully", data["message"]) + # Check that edge cases are handled correctly result_models = data["data"] self.assertEqual(len(result_models), 4) - + # Verify special characters, numbers, and case sensitivity - special_model = next((m for m in result_models if m["id"] == "silicon/model_with_special_chars"), None) - number_model = next((m for m in result_models if m["id"] == "silicon/model_with_numbers_123"), None) - uppercase_model = next((m for m in result_models if m["id"] == "silicon/MODEL_WITH_UPPERCASE"), None) - unknown_model = next((m for m in result_models if m["id"] == "silicon/unknown_model"), None) - + special_model = next( + (m for m in result_models if m["id"] == "silicon/model_with_special_chars"), None) + number_model = next( + (m for m in result_models if m["id"] == "silicon/model_with_numbers_123"), None) + uppercase_model = next( + (m for m in result_models if m["id"] == "silicon/MODEL_WITH_UPPERCASE"), None) + unknown_model = next( + (m for m in result_models if m["id"] == "silicon/unknown_model"), None) + self.assertIsNotNone(special_model) self.assertEqual(special_model.get("max_tokens"), 4096) self.assertIsNotNone(number_model) @@ -1819,36 +2029,202 @@ def test_create_provider_model_with_existing_models_edge_cases(self, mock_get_ex self.assertEqual(uppercase_model.get("max_tokens"), 16384) self.assertIsNotNone(unknown_model) self.assertNotIn("max_tokens", unknown_model) - - mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_get_existing.assert_called_once_with(self.tenant_id, "silicon", "llm") + + mock_get_user.assert_called_once_with( + self.auth_header["Authorization"]) + mock_get_existing.assert_called_once_with( + self.tenant_id, "silicon", "llm") def test_create_provider_model_silicon_success_backend(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "get_provider_models", new=AsyncMock(return_value=[{"id": "b2"}, {"id": "A1"}, {"id": "a0"}, {"id": "c3"}])) as mock_get: + with patch.object(backend_model_app, "create_provider_models_for_tenant", new=AsyncMock(return_value=[{"id": "A1"}, {"id": "a0"}, {"id": "b2"}, {"id": "c3"}])) as mock_get: request_data = {"provider": "silicon", "api_key": "test_key"} - response = backend_client_local.post("/model/create_provider", json=request_data, headers=self.auth_header) + response = backend_client_local.post( + "/model/provider/create", json=request_data, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data["code"], 200) - self.assertIn("Provider model silicon created successfully", data["message"]) + self.assertIn( + "Provider model created successfully", data["message"]) # Check that models are sorted by first letter in ascending order - self.assertEqual([m["id"] for m in data["data"]], ["A1", "a0", "b2", "c3"]) + self.assertEqual([m["id"] for m in data["data"]], [ + "A1", "a0", "b2", "c3"]) mock_get.assert_called_once() def test_create_provider_model_exception_backend(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "get_provider_models", new=AsyncMock(side_effect=Exception("Provider API error"))) as mock_get: + with patch.object(backend_model_app, "create_provider_models_for_tenant", new=AsyncMock(side_effect=Exception("Provider API error"))) as mock_get: request_data = {"provider": "silicon", "api_key": "test_key"} - response = backend_client_local.post("/model/create_provider", json=request_data, headers=self.auth_header) - self.assertEqual(response.status_code, 200) + response = backend_client_local.post( + "/model/provider/create", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 500) data = response.json() - self.assertEqual(data["code"], 500) - self.assertIn("Failed to create provider model: Provider API error", data["message"]) + self.assertIn("Failed to create provider model", + data.get("detail", "")) mock_get.assert_called_once() + def test_create_model_success_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + async def _create(*args, **kwargs): + return None + with patch.object(backend_model_app, "create_model_for_tenant", side_effect=_create) as mock_create: + response = backend_client_local.post( + "/model/create", json=self.model_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("Model created successfully", + data.get("message", "")) + mock_create.assert_called_once() + + def test_create_model_conflict_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "create_model_for_tenant", new=AsyncMock(side_effect=ValueError("Name conflict"))) as mock_create: + response = backend_client_local.post( + "/model/create", json=self.model_data, headers=self.auth_header) + self.assertEqual(response.status_code, 409) + data = response.json() + self.assertIn( + "Failed to create model: name conflict", data.get("detail", "")) + mock_create.assert_called_once() + + def test_create_model_exception_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "create_model_for_tenant", new=AsyncMock(side_effect=Exception("DB failure"))) as mock_create: + response = backend_client_local.post( + "/model/create", json=self.model_data, headers=self.auth_header) + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn("Failed to create model", data.get("detail", "")) + mock_create.assert_called_once() + + def test_provider_batch_create_success_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + async def _batch(*args, **kwargs): + return None + with patch.object(backend_model_app, "batch_create_models_for_tenant", side_effect=_batch) as mock_batch: + payload = { + "models": [{"id": "prov/modelA"}], + "provider": "prov", + "type": "llm", + "api_key": "k", + } + response = backend_client_local.post( + "/model/provider/batch_create", json=payload, headers=self.auth_header) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("Batch create models successfully", + data.get("message", "")) + mock_batch.assert_called_once() + + def test_provider_batch_create_exception_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "batch_create_models_for_tenant", new=AsyncMock(side_effect=Exception("boom"))) as mock_batch: + payload = { + "models": [{"id": "prov/modelA"}], + "provider": "prov", + "type": "llm", + } + response = backend_client_local.post( + "/model/provider/batch_create", json=payload, headers=self.auth_header) + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn("Failed to batch create models", + data.get("detail", "")) + mock_batch.assert_called_once() + + def test_delete_model_success_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + async def _delete(*args, **kwargs): + return "Test Model" + with patch.object(backend_model_app, "delete_model_for_tenant", side_effect=_delete) as mock_del: + response = backend_client_local.post( + "/model/delete", params={"display_name": "Test Model"}, headers=self.auth_header) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("Model deleted successfully", + data.get("message", "")) + self.assertEqual(data.get("data"), "Test Model") + mock_del.assert_called_once() + + def test_delete_model_not_found_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "delete_model_for_tenant", new=AsyncMock(side_effect=LookupError("x"))) as mock_del: + response = backend_client_local.post( + "/model/delete", params={"display_name": "Missing"}, headers=self.auth_header) + self.assertEqual(response.status_code, 404) + data = response.json() + self.assertIn( + "Failed to delete model: model not found", data.get("detail", "")) + mock_del.assert_called_once() + + def test_delete_model_exception_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "delete_model_for_tenant", new=AsyncMock(side_effect=Exception("db"))) as mock_del: + response = backend_client_local.post( + "/model/delete", params={"display_name": "X"}, headers=self.auth_header) + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn("Failed to delete model", data.get("detail", "")) + mock_del.assert_called_once() + + def test_check_model_health_lookup_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "check_model_connectivity", new=AsyncMock(side_effect=LookupError("missing"))) as mock_chk: + response = backend_client_local.post( + "/model/healthcheck", params={"display_name": "Test Model"}, headers=self.auth_header) + self.assertEqual(response.status_code, 404) + data = response.json() + self.assertIn("Model configuration not found", + data.get("detail", "")) + mock_chk.assert_called_once_with("Test Model", self.tenant_id) + + def test_check_model_health_bad_request_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "check_model_connectivity", new=AsyncMock(side_effect=ValueError("bad"))) as mock_chk: + response = backend_client_local.post( + "/model/healthcheck", params={"display_name": "Test Model"}, headers=self.auth_header) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertIn("Invalid model configuration", + data.get("detail", "")) + mock_chk.assert_called_once_with("Test Model", self.tenant_id) + + def test_check_model_health_exception_backend(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "check_model_connectivity", new=AsyncMock(side_effect=Exception("err"))) as mock_chk: + response = backend_client_local.post( + "/model/healthcheck", params={"display_name": "Test Model"}, headers=self.auth_header) + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn("Failed to check model connectivity", + data.get("detail", "")) + mock_chk.assert_called_once_with("Test Model", self.tenant_id) + @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_model_by_display_name") @patch("test_model_managment_app.delete_model_record") @@ -1862,7 +2238,8 @@ def test_delete_model_success(self, mock_delete, mock_get_by_display, mock_get_u } # Send request - response = client.post("/model/delete", params={"display_name": "Test Model"}, headers=self.auth_header) + response = client.post( + "/model/delete", params={"display_name": "Test Model"}, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -1871,7 +2248,8 @@ def test_delete_model_success(self, mock_delete, mock_get_by_display, mock_get_u self.assertIn("Successfully deleted model", data["message"]) # Verify mock calls - mock_delete.assert_called_once_with("test_model_id", self.user_id, self.tenant_id) + mock_delete.assert_called_once_with( + "test_model_id", self.user_id, self.tenant_id) @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_model_by_display_name") @@ -1879,7 +2257,7 @@ def test_delete_model_success(self, mock_delete, mock_get_by_display, mock_get_u def test_delete_embedding_model(self, mock_delete, mock_get_by_display, mock_get_user): # Configure mocks mock_get_user.return_value = (self.user_id, self.tenant_id) - + # 修正模拟返回值的顺序和内容 # 第一次调用返回embedding类型模型(初始检查) # 第二次调用返回embedding类型模型(在循环中检查"embedding"类型) @@ -1899,7 +2277,8 @@ def test_delete_embedding_model(self, mock_delete, mock_get_by_display, mock_get ] # Send request - response = client.post("/model/delete", params={"display_name": "Test Embedding"}, headers=self.auth_header) + response = client.post( + "/model/delete", params={"display_name": "Test Embedding"}, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -1908,7 +2287,8 @@ def test_delete_embedding_model(self, mock_delete, mock_get_by_display, mock_get self.assertIn("Successfully deleted model", data["message"]) # Verify mock was called with correct model_id - mock_delete.assert_called_once_with("embedding_id", self.user_id, self.tenant_id) + mock_delete.assert_called_once_with( + "embedding_id", self.user_id, self.tenant_id) @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_model_by_display_name") @@ -1916,7 +2296,7 @@ def test_delete_embedding_model(self, mock_delete, mock_get_by_display, mock_get def test_delete_multi_embedding_model(self, mock_delete, mock_get_by_display, mock_get_user): """Test deletion of multi_embedding model with mutual deletion logic""" mock_get_user.return_value = (self.user_id, self.tenant_id) - + # Mock multi_embedding model deletion scenario # First call: initial check returns multi_embedding model # Second call: loop check for "embedding" type returns None @@ -1936,7 +2316,8 @@ def test_delete_multi_embedding_model(self, mock_delete, mock_get_by_display, mo ] # Send request - response = client.post("/model/delete", params={"display_name": "Test Multi Embedding"}, headers=self.auth_header) + response = client.post( + "/model/delete", params={"display_name": "Test Multi Embedding"}, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -1945,7 +2326,8 @@ def test_delete_multi_embedding_model(self, mock_delete, mock_get_by_display, mo self.assertIn("Successfully deleted model", data["message"]) # Verify mock was called with correct model_id - mock_delete.assert_called_once_with("multi_embedding_id", self.user_id, self.tenant_id) + mock_delete.assert_called_once_with( + "multi_embedding_id", self.user_id, self.tenant_id) @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_model_by_display_name") @@ -1953,7 +2335,7 @@ def test_delete_multi_embedding_model(self, mock_delete, mock_get_by_display, mo def test_delete_embedding_model_with_both_types(self, mock_delete, mock_get_by_display, mock_get_user): """Test deletion of embedding model when both embedding and multi_embedding exist""" mock_get_user.return_value = (self.user_id, self.tenant_id) - + # Mock scenario where both embedding and multi_embedding models exist # First call: initial check returns embedding model # Second call: loop check for "embedding" type returns the model @@ -1977,7 +2359,8 @@ def test_delete_embedding_model_with_both_types(self, mock_delete, mock_get_by_d ] # Send request - response = client.post("/model/delete", params={"display_name": "Test Embedding"}, headers=self.auth_header) + response = client.post( + "/model/delete", params={"display_name": "Test Embedding"}, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -1987,8 +2370,10 @@ def test_delete_embedding_model_with_both_types(self, mock_delete, mock_get_by_d # Verify both models were deleted self.assertEqual(mock_delete.call_count, 2) - mock_delete.assert_any_call("embedding_id", self.user_id, self.tenant_id) - mock_delete.assert_any_call("multi_embedding_id", self.user_id, self.tenant_id) + mock_delete.assert_any_call( + "embedding_id", self.user_id, self.tenant_id) + mock_delete.assert_any_call( + "multi_embedding_id", self.user_id, self.tenant_id) @patch("test_model_managment_app.get_current_user_id") @patch("test_model_managment_app.get_model_by_display_name") @@ -1998,7 +2383,8 @@ def test_delete_model_not_found(self, mock_get_by_display, mock_get_user): mock_get_by_display.return_value = None # Send request - response = client.post("/model/delete", params={"display_name": "NonExistentModel"}, headers=self.auth_header) + response = client.post( + "/model/delete", params={"display_name": "NonExistentModel"}, headers=self.auth_header) # Assert response self.assertEqual(response.status_code, 200) @@ -2006,202 +2392,181 @@ def test_delete_model_not_found(self, mock_get_by_display, mock_get_user): self.assertEqual(data["code"], 404) self.assertIn("Model not found", data["message"]) - @patch("test_model_managment_app.get_current_user_id") - @patch("test_model_managment_app.get_model_records") - def test_get_model_list(self, mock_get_records, mock_get_user): - # Configure mocks - mock_get_user.return_value = (self.user_id, self.tenant_id) - mock_get_records.return_value = [ - { - "model_id": "model1", - "model_name": "llama", - "model_repo": "huggingface", - "display_name": "LLaMA Model", - "model_type": "llm", - "connect_status": ModelConnectStatusEnum.OPERATIONAL - }, - { - "model_id": "model2", - "model_name": "clip", - "model_repo": "openai", - "display_name": "CLIP Model", - "model_type": "embedding", - "connect_status": None - } - ] - - # Send request - response = client.get("/model/list", headers=self.auth_header) - - # Assert response - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 200) - self.assertEqual(len(data["data"]), 2) - self.assertEqual(data["data"][0]["model_name"], "huggingface/llama") - self.assertEqual(data["data"][1]["model_name"], "openai/clip") - self.assertEqual(data["data"][1]["connect_status"], ModelConnectStatusEnum.NOT_DETECTED) - - @patch("test_model_managment_app.get_current_user_id") - @patch("test_model_managment_app.get_model_records") - def test_get_model_list_exception(self, mock_get_records, mock_get_user): - # Configure mocks - mock_get_user.return_value = (self.user_id, self.tenant_id) - mock_get_records.side_effect = Exception("Database error") - - # Send request - response = client.get("/model/list", headers=self.auth_header) - - # Assert response - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 500) - self.assertIn("An internal error occurred while retrieving the model list.", data["message"]) - self.assertEqual(data["data"], []) - - @patch("test_model_managment_app.check_model_connectivity") - def test_check_model_healthcheck(self, mock_check_connectivity): - # Configure mock - mock_check_connectivity.return_value = { - "code": 200, - "message": "Model is operational", - "data": { - "connectivity": True, - "connect_status": ModelConnectStatusEnum.OPERATIONAL - } - } - - # Send request - response = client.post( - "/model/healthcheck", - params={"display_name": "Test Model"}, - headers=self.auth_header - ) - - # Assert response - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 200) - self.assertEqual(data["message"], "Model is operational") - self.assertTrue(data["data"]["connectivity"]) - - # Verify mock call - mock_check_connectivity.assert_called_once() - - @patch("test_model_managment_app.verify_model_config_connectivity") - def test_verify_model_config(self, mock_verify_config): - # Configure mock - mock_verify_config.return_value = { - "code": 200, - "message": "Configuration verified successfully", - "data": { - "connectivity": True, - "connect_status": ModelConnectStatusEnum.OPERATIONAL - } - } - - # Send request - response = client.post("/model/verify_config", json=self.model_data) - - # Assert response - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 200) - self.assertEqual(data["message"], "Configuration verified successfully") - self.assertTrue(data["data"]["connectivity"]) + def test_get_model_list(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + async def mock_list_models(*args, **kwargs): + return [ + { + "model_id": "model1", + "model_name": "huggingface/llama", + "display_name": "LLaMA Model", + "model_type": "llm", + "connect_status": "operational" + }, + { + "model_id": "model2", + "model_name": "openai/clip", + "display_name": "CLIP Model", + "model_type": "embedding", + "connect_status": "not_detected" + } + ] + with patch.object(backend_model_app, "list_models_for_tenant", side_effect=mock_list_models) as mock_list: + response = backend_client_local.get( + "/model/list", headers=self.auth_header) - # Verify mock call - mock_verify_config.assert_called_once() + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn( + "Successfully retrieved model list", data["message"]) + self.assertEqual(len(data["data"]), 2) + self.assertEqual( + data["data"][0]["model_name"], "huggingface/llama") + self.assertEqual(data["data"][1]["model_name"], "openai/clip") + self.assertEqual( + data["data"][1]["connect_status"], "not_detected") + mock_list.assert_called_once_with(self.tenant_id) + + def test_get_model_list_exception(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + async def mock_list_models(*args, **kwargs): + raise Exception("Database error") + with patch.object(backend_model_app, "list_models_for_tenant", side_effect=mock_list_models): + response = backend_client_local.get( + "/model/list", headers=self.auth_header) - @patch("test_model_managment_app.verify_model_config_connectivity") - def test_verify_model_config_exception(self, mock_verify_config): - # Configure mock - mock_verify_config.side_effect = Exception("Connection error") + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn("Failed to retrieve model list", + data.get("detail", "")) - # Send request - response = client.post("/model/verify_config", json=self.model_data) + def test_check_model_healthcheck(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + with patch.object(backend_model_app, "check_model_connectivity", return_value={"connectivity": True, "connect_status": "available"}) as mock_check: + response = backend_client_local.post( + "/model/healthcheck", + params={"display_name": "Test Model"}, + headers=self.auth_header + ) - # Assert response - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 500) - self.assertIn("验证模型配置失败", data["message"]) - self.assertFalse(data["data"]["connectivity"]) - self.assertEqual(data["data"]["connect_status"], ModelConnectStatusEnum.UNAVAILABLE) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual( + data["message"], "Successfully checked model connectivity") + self.assertTrue(data["data"]["connectivity"]) + mock_check.assert_called_once_with( + "Test Model", self.tenant_id) + + def test_verify_model_config(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "verify_model_config_connectivity", return_value={"connectivity": True, "model_name": "gpt-4"}) as mock_verify: + response = backend_client_local.post( + "/model/temporary_healthcheck", json=self.model_data) - @patch("test_model_managment_app.get_current_user_id") - @patch("test_model_managment_app.get_models_by_tenant_factory_type") - def test_get_provider_list(self, mock_get_models, mock_get_user): - # 配置 mock - mock_get_user.return_value = (self.user_id, self.tenant_id) - mock_get_models.return_value = [ - { - "model_repo": "huggingface", - "model_name": "llama", - "model_type": "llm" - }, - { - "model_repo": "openai", - "model_name": "clip", - "model_type": "embedding" - } - ] - request_data = { - "provider": "huggingface", - "model_type": "llm", - "api_key": "test_key" - } - response = client.post("/model/provider/list", json=request_data, headers=self.auth_header) - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 200) - self.assertIn("created successfully", data["message"]) - self.assertEqual(len(data["data"]), 2) - self.assertEqual(data["data"][0]["id"], "huggingface/llama") - self.assertEqual(data["data"][1]["id"], "openai/clip") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual( + data["message"], "Successfully verified model connectivity") + self.assertTrue(data["data"]["connectivity"]) + mock_verify.assert_called_once() + + def test_verify_model_config_exception(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "verify_model_config_connectivity", side_effect=Exception("Connection error")): + response = backend_client_local.post( + "/model/temporary_healthcheck", json=self.model_data) + + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn("Failed to verify model connectivity", + data.get("detail", "")) - @patch("test_model_managment_app.get_current_user_id") - @patch("test_model_managment_app.get_models_by_tenant_factory_type") - def test_get_provider_list_exception(self, mock_get_models, mock_get_user): - mock_get_user.return_value = (self.user_id, self.tenant_id) - mock_get_models.side_effect = Exception("DB error") - request_data = { - "provider": "huggingface", - "model_type": "llm", - "api_key": "test_key" - } - response = client.post("/model/provider/list", json=request_data, headers=self.auth_header) - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 500) - self.assertIn("Failed to get provider list", data["message"]) + def test_get_provider_list(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + async def mock_list_provider_models(*args, **kwargs): + return [ + {"model_repo": "huggingface", "model_name": "llama", + "model_type": "llm", "id": "huggingface/llama"}, + {"model_repo": "openai", "model_name": "clip", + "model_type": "embedding", "id": "openai/clip"}, + ] + with patch.object(backend_model_app, "list_provider_models_for_tenant", side_effect=mock_list_provider_models) as mock_list: + request_data = { + "provider": "huggingface", + "model_type": "llm", + "api_key": "test_key" + } + response = backend_client_local.post( + "/model/provider/list", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn( + "Successfully retrieved provider list", data["message"]) + self.assertEqual(len(data["data"]), 2) + self.assertEqual(data["data"][0]["id"], "huggingface/llama") + self.assertEqual(data["data"][1]["id"], "openai/clip") + mock_list.assert_called_once_with( + self.tenant_id, "huggingface", "llm") + + def test_get_provider_list_exception(self): + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") + with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): + async def mock_list_provider_models(*args, **kwargs): + raise Exception("DB error") + with patch.object(backend_model_app, "list_provider_models_for_tenant", side_effect=mock_list_provider_models): + request_data = { + "provider": "huggingface", + "model_type": "llm", + "api_key": "test_key" + } + response = backend_client_local.post( + "/model/provider/list", json=request_data, headers=self.auth_header) + self.assertEqual(response.status_code, 500) + data = response.json() + self.assertIn("Failed to get provider list", + data.get("detail", "")) def test_update_single_model_success(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "get_model_by_display_name", return_value=None) as mock_get_by_display: - with patch.object(backend_model_app, "update_model_record") as mock_update: - update_data = { - "model_id": "test_model_id", - "model_name": "huggingface/llama", - "display_name": "Updated Test Model", - "api_base": "http://localhost:8001", - "api_key": "updated_key", - "model_type": "llm", - "provider": "huggingface" - } - response = backend_client_local.post("/model/update_single_model", json=update_data, headers=self.auth_header) - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 200) - self.assertIn("Updated Test Model updated successfully", data["message"]) - mock_get_by_display.assert_called_once_with("Updated Test Model", self.tenant_id) - mock_update.assert_called_once_with("test_model_id", update_data, self.user_id) + async def mock_update_single(*args, **kwargs): + return None + with patch.object(backend_model_app, "update_single_model_for_tenant", side_effect=mock_update_single) as mock_update_single: + update_data = { + "model_id": "test_model_id", + "model_name": "huggingface/llama", + "display_name": "Updated Test Model", + "api_base": "http://localhost:8001", + "api_key": "updated_key", + "model_type": "llm", + "provider": "huggingface" + } + response = backend_client_local.post( + "/model/update", json=update_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("Model updated successfully", + data["message"]) + mock_update_single.assert_called_once_with( + self.user_id, self.tenant_id, update_data) def test_update_single_model_display_name_conflict(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "get_model_by_display_name", return_value={"model_id": "other_model_id", "display_name": "Conflicting Name"}) as mock_get_by_display: + with patch.object(backend_model_app, "update_single_model_for_tenant", side_effect=ValueError("Name conflict")) as mock_update_single: update_data = { "model_id": "test_model_id", "model_name": "huggingface/llama", @@ -2211,39 +2576,47 @@ def test_update_single_model_display_name_conflict(self): "model_type": "llm", "provider": "huggingface" } - response = backend_client_local.post("/model/update_single_model", json=update_data, headers=self.auth_header) - self.assertEqual(response.status_code, 500) + response = backend_client_local.post( + "/model/update", json=update_data, headers=self.auth_header) + self.assertEqual(response.status_code, 409) data = response.json() - self.assertIn("Failed to update model", data.get("detail", "")) - self.assertIn("already in use", data.get("detail", "")) - mock_get_by_display.assert_called_once_with("Conflicting Name", self.tenant_id) + self.assertIn( + "Failed to update model: name conflict", data.get("detail", "")) + mock_update_single.assert_called_once_with( + self.user_id, self.tenant_id, update_data) def test_update_single_model_same_model_id_no_conflict(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "get_model_by_display_name", return_value={"model_id": "test_model_id", "display_name": "Same Display Name"}) as mock_get_by_display: - with patch.object(backend_model_app, "update_model_record") as mock_update: - update_data = { - "model_id": "test_model_id", - "model_name": "huggingface/llama", - "display_name": "Same Display Name", - "api_base": "http://localhost:8001", - "api_key": "updated_key", - "model_type": "llm", - "provider": "huggingface" - } - response = backend_client_local.post("/model/update_single_model", json=update_data, headers=self.auth_header) - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["code"], 200) - self.assertIn("Same Display Name updated successfully", data["message"]) - mock_get_by_display.assert_called_once_with("Same Display Name", self.tenant_id) - mock_update.assert_called_once_with("test_model_id", update_data, self.user_id) + async def mock_update_single(*args, **kwargs): + return None + with patch.object(backend_model_app, "update_single_model_for_tenant", side_effect=mock_update_single) as mock_update_single: + update_data = { + "model_id": "test_model_id", + "model_name": "huggingface/llama", + "display_name": "Same Display Name", + "api_base": "http://localhost:8001", + "api_key": "updated_key", + "model_type": "llm", + "provider": "huggingface" + } + response = backend_client_local.post( + "/model/update", json=update_data, headers=self.auth_header) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("Model updated successfully", + data["message"]) + mock_update_single.assert_called_once_with( + self.user_id, self.tenant_id, update_data) def test_update_single_model_exception(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "update_model_record", side_effect=Exception("Database update error")) as mock_update: + async def mock_update_single(*args, **kwargs): + raise Exception("Database update error") + with patch.object(backend_model_app, "update_single_model_for_tenant", side_effect=mock_update_single) as mock_update_single: update_data = { "model_id": "test_model_id", "model_name": "huggingface/llama", @@ -2253,55 +2626,69 @@ def test_update_single_model_exception(self): "model_type": "llm", "provider": "huggingface" } - response = backend_client_local.post("/model/update_single_model", json=update_data, headers=self.auth_header) + response = backend_client_local.post( + "/model/update", json=update_data, headers=self.auth_header) self.assertEqual(response.status_code, 500) data = response.json() - self.assertIn("Failed to update model: Database update error", data.get("detail", "")) - mock_update.assert_called_once() + self.assertIn("Failed to update model", data.get("detail", "")) + mock_update_single.assert_called_once_with( + self.user_id, self.tenant_id, update_data) def test_batch_update_models_success_backend(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "update_model_record") as mock_update: + async def mock_batch_update(*args, **kwargs): + return None + with patch.object(backend_model_app, "batch_update_models_for_tenant", side_effect=mock_batch_update) as mock_batch_update: models = [ {"model_id": "id1", "api_key": "k1", "max_tokens": 100}, {"model_id": "id2", "api_key": "k2", "max_tokens": 200}, ] - response = backend_client_local.post("/model/batch_update_models", json=models, headers=self.auth_header) + response = backend_client_local.post( + "/model/batch_update", json=models, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data["code"], 200) - self.assertIn("Batch update models successfully", data["message"]) - self.assertEqual(mock_update.call_count, 2) - mock_update.assert_any_call("id1", models[0], self.user_id) - mock_update.assert_any_call("id2", models[1], self.user_id) + self.assertIn("Batch update models successfully", + data["message"]) + mock_batch_update.assert_called_once_with( + self.user_id, self.tenant_id, models) def test_batch_update_models_exception_backend(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)): - with patch.object(backend_model_app, "update_model_record", side_effect=Exception("Update failed")) as mock_update: + async def mock_batch_update(*args, **kwargs): + raise Exception("Update failed") + with patch.object(backend_model_app, "batch_update_models_for_tenant", side_effect=mock_batch_update) as mock_batch_update: models = [ {"model_id": "id1", "api_key": "k1"} ] - response = backend_client_local.post("/model/batch_update_models", json=models, headers=self.auth_header) - self.assertEqual(response.status_code, 200) + response = backend_client_local.post( + "/model/batch_update", json=models, headers=self.auth_header) + self.assertEqual(response.status_code, 500) data = response.json() - self.assertEqual(data["code"], 500) - self.assertIn("Failed to batch update models: Update failed", data["message"]) - + self.assertIn("Failed to batch update models", + data.get("detail", "")) + mock_batch_update.assert_called_once_with( + self.user_id, self.tenant_id, models) def test_batch_update_models_empty_list_backend(self): - backend_client_local, backend_model_app = _build_backend_client_with_s3_stub() + backend_client_local, backend_model_app = _build_backend_client_with_s3_stub( + "backend.apps.model_managment_app") with patch.object(backend_model_app, "get_current_user_id", return_value=(self.user_id, self.tenant_id)) as mock_get_user: - with patch.object(backend_model_app, "update_model_record") as mock_update: + async def mock_batch_update(*args, **kwargs): + return None + with patch.object(backend_model_app, "batch_update_models_for_tenant", side_effect=mock_batch_update) as mock_batch_update: models = [] - response = backend_client_local.post("/model/batch_update_models", json=models, headers=self.auth_header) + response = backend_client_local.post( + "/model/batch_update", json=models, headers=self.auth_header) self.assertEqual(response.status_code, 200) data = response.json() - self.assertEqual(data["code"], 200) self.assertIn("Batch update models successfully", data["message"]) mock_get_user.assert_called_once_with(self.auth_header["Authorization"]) - mock_update.assert_not_called() + mock_batch_update.assert_called_once_with( + self.user_id, self.tenant_id, models) if __name__ == "__main__": diff --git a/test/backend/app/test_user_management_app.py b/test/backend/app/test_user_management_app.py index 259ae8dbf..5085377b6 100644 --- a/test/backend/app/test_user_management_app.py +++ b/test/backend/app/test_user_management_app.py @@ -6,12 +6,13 @@ # Add path for correct imports sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../../backend")) +# Import exception classes +from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException +from supabase_auth.errors import AuthApiError, AuthWeakPasswordError + # Mock external dependencies sys.modules['boto3'] = MagicMock() -# Import exception classes -from consts.exceptions import NoInviteCodeException, IncorrectInviteCodeException, UserRegistrationException -from gotrue.errors import AuthApiError, AuthWeakPasswordError # Import the modules we need with MinioClient mocked with patch('database.client.MinioClient', MagicMock()): diff --git a/test/backend/services/test_model_health_service.py b/test/backend/services/test_model_health_service.py index f3dd5d3cd..98f5195c0 100644 --- a/test/backend/services/test_model_health_service.py +++ b/test/backend/services/test_model_health_service.py @@ -198,7 +198,8 @@ async def test_perform_connectivity_check_llm(): "gpt-4", "llm", "https://api.openai.com", - "test-key" + "test-key", + 0 ) # Assert @@ -230,7 +231,8 @@ async def test_perform_connectivity_check_vlm(): "gpt-4-vision", "vlm", "https://api.openai.com", - "test-key" + "test-key", + 0 ) # Assert @@ -260,7 +262,8 @@ async def test_perform_connectivity_check_tts(): "tts-1", "tts", "https://api.openai.com", - "test-key" + "test-key", + 0 ) # Assert @@ -284,7 +287,8 @@ async def test_perform_connectivity_check_stt(): "whisper-1", "stt", "https://api.openai.com", - "test-key" + "test-key", + 0 ) # Assert @@ -299,7 +303,8 @@ async def test_perform_connectivity_check_rerank(): "rerank-model", "rerank", "https://api.example.com", - "test-key" + "test-key", + 0 ) # Assert @@ -314,7 +319,8 @@ async def test_perform_connectivity_check_unsupported_type(): "unsupported-model", "unsupported_type", "https://api.example.com", - "test-key" + "test-key", + 0 ) assert "Unsupported model type" in str(excinfo.value) @@ -324,17 +330,14 @@ async def test_perform_connectivity_check_unsupported_type(): async def test_check_model_connectivity_success(): # Setup with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ - mock.patch("backend.services.model_health_service.get_current_user_id") as mock_get_user_id, \ mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ mock.patch("backend.services.model_health_service.update_model_record") as mock_update_model, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum: mock_enum.AVAILABLE.value = "available" mock_enum.UNAVAILABLE.value = "unavailable" mock_enum.DETECTING.value = "detecting" - mock_get_user_id.return_value = ("user123", "tenant456") mock_get_model.return_value = { "model_id": "model123", "model_repo": "openai", @@ -346,16 +349,17 @@ async def test_check_model_connectivity_success(): mock_connectivity_check.return_value = True # Execute - response = await check_model_connectivity("GPT-4", "Bearer test-token") + response = await check_model_connectivity("GPT-4", "tenant456") # Assert - assert response.code == 200 - assert response.data["connectivity"] is True - assert response.data["connect_status"] == "available" + assert response["connectivity"] is True + assert response["connect_status"] == "available" - mock_get_user_id.assert_called_once_with("Bearer test-token") mock_get_model.assert_called_once_with("GPT-4", tenant_id="tenant456") - mock_update_model.assert_called_with( + # Detecting first, then available + mock_update_model.assert_any_call( + "model123", {"connect_status": "detecting"}) + mock_update_model.assert_any_call( "model123", {"connect_status": "available"}) mock_connectivity_check.assert_called_once_with( "openai/gpt-4", "llm", "https://api.openai.com", "test-key" @@ -365,37 +369,27 @@ async def test_check_model_connectivity_success(): @pytest.mark.asyncio async def test_check_model_connectivity_model_not_found(): # Setup - with mock.patch("backend.services.model_health_service.get_current_user_id") as mock_get_user_id, \ - mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + with mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model: - mock_get_user_id.return_value = ("user123", "tenant456") mock_get_model.return_value = None - # Execute - response = await check_model_connectivity("NonexistentModel", "Bearer test-token") - - # Assert - assert response.code == 404 - assert response.data["connectivity"] is False - assert response.data["connect_status"] == "Not Found" + # Execute & Assert + with pytest.raises(LookupError): + await check_model_connectivity("NonexistentModel", "tenant456") @pytest.mark.asyncio async def test_check_model_connectivity_failure(): # Setup with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ - mock.patch("backend.services.model_health_service.get_current_user_id") as mock_get_user_id, \ mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ mock.patch("backend.services.model_health_service.update_model_record") as mock_update_model, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum: mock_enum.AVAILABLE.value = "available" mock_enum.UNAVAILABLE.value = "unavailable" mock_enum.DETECTING.value = "detecting" - mock_get_user_id.return_value = ("user123", "tenant456") mock_get_model.return_value = { "model_id": "model123", "model_name": "gpt-4", @@ -406,15 +400,14 @@ async def test_check_model_connectivity_failure(): mock_connectivity_check.return_value = False # Execute - response = await check_model_connectivity("GPT-4", "Bearer test-token") + response = await check_model_connectivity("GPT-4", "tenant456") # Assert - assert response.code == 200 - assert response.data["connectivity"] is False - assert response.data["connect_status"] == "unavailable" + assert response["connectivity"] is False + assert response["connect_status"] == "unavailable" # Check that we updated the model status to unavailable - mock_update_model.assert_called_with( + mock_update_model.assert_any_call( "model123", {"connect_status": "unavailable"}) @@ -422,17 +415,14 @@ async def test_check_model_connectivity_failure(): async def test_check_model_connectivity_exception(): # Setup with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ - mock.patch("backend.services.model_health_service.get_current_user_id") as mock_get_user_id, \ mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ mock.patch("backend.services.model_health_service.update_model_record") as mock_update_model, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum: mock_enum.AVAILABLE.value = "available" mock_enum.UNAVAILABLE.value = "unavailable" mock_enum.DETECTING.value = "detecting" - mock_get_user_id.return_value = ("user123", "tenant456") mock_get_model.return_value = { "model_id": "model123", "model_name": "gpt-4", @@ -443,42 +433,31 @@ async def test_check_model_connectivity_exception(): mock_connectivity_check.side_effect = ValueError( "Unsupported model type") - # Execute - response = await check_model_connectivity("GPT-4", "Bearer test-token") - - # Assert - assert response.code == 400 - assert response.data["connectivity"] is False - assert response.data["connect_status"] == "unavailable" + # Execute & Assert + with pytest.raises(ValueError): + await check_model_connectivity("GPT-4", "tenant456") # Check that we updated the model status to unavailable - mock_update_model.assert_called_with( + mock_update_model.assert_any_call( "model123", {"connect_status": "unavailable"}) @pytest.mark.asyncio async def test_check_model_connectivity_general_exception(): # Setup - with mock.patch("backend.services.model_health_service.get_current_user_id") as mock_get_user_id, \ - mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ + with mock.patch("backend.services.model_health_service.get_model_by_display_name") as mock_get_model, \ mock.patch("backend.services.model_health_service.update_model_record") as mock_update_model, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum: mock_enum.AVAILABLE.value = "available" mock_enum.UNAVAILABLE.value = "unavailable" mock_enum.DETECTING.value = "detecting" - mock_get_user_id.return_value = ("user123", "tenant456") mock_get_model.side_effect = Exception("Database error") - # Execute - response = await check_model_connectivity("GPT-4", "Bearer test-token") - - # Assert - assert response.code == 500 - assert response.data["connectivity"] is False - assert response.data["connect_status"] == "unavailable" + # Execute & Assert + with pytest.raises(Exception): + await check_model_connectivity("GPT-4", "tenant456") # Should not update model record since we had an exception before getting to that point mock_update_model.assert_not_called() @@ -487,11 +466,8 @@ async def test_check_model_connectivity_general_exception(): @pytest.mark.asyncio async def test_verify_model_config_connectivity_success(): # Setup - with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check: - mock_enum.AVAILABLE.value = "available" mock_connectivity_check.return_value = True model_config = { @@ -506,10 +482,8 @@ async def test_verify_model_config_connectivity_success(): response = await verify_model_config_connectivity(model_config) # Assert - assert response.code == 200 - assert response.data["connectivity"] is True - assert response.data["connect_status"] == "available" - assert response.data["error_code"] == "MODEL_VALIDATION_SUCCESS" + assert response["connectivity"] is True + assert response["model_name"] == "gpt-4" mock_connectivity_check.assert_called_once_with( "gpt-4", "llm", "https://api.openai.com", "test-key", 2048 @@ -519,11 +493,8 @@ async def test_verify_model_config_connectivity_success(): @pytest.mark.asyncio async def test_verify_model_config_connectivity_failure(): # Setup - with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check: - mock_enum.UNAVAILABLE.value = "unavailable" mock_connectivity_check.return_value = False model_config = { @@ -537,20 +508,15 @@ async def test_verify_model_config_connectivity_failure(): response = await verify_model_config_connectivity(model_config) # Assert - assert response.code == 200 - assert response.data["connectivity"] is False - assert response.data["connect_status"] == "unavailable" - assert response.data["error_code"] == "MODEL_VALIDATION_FAILED" + assert response["connectivity"] is False + assert response["model_name"] == "gpt-4" @pytest.mark.asyncio async def test_verify_model_config_connectivity_validation_error(): # Setup - with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check: - mock_enum.UNAVAILABLE.value = "unavailable" mock_connectivity_check.side_effect = ValueError("Invalid model type") model_config = { @@ -564,21 +530,15 @@ async def test_verify_model_config_connectivity_validation_error(): response = await verify_model_config_connectivity(model_config) # Assert - assert response.code == 400 - assert response.data["connectivity"] is False - assert response.data["connect_status"] == "unavailable" - assert response.data["error_code"] == "MODEL_VALIDATION_ERROR" - assert response.data["message"] == "Invalid model type" + assert response["connectivity"] is False + assert response["model_name"] == "invalid-model" @pytest.mark.asyncio async def test_verify_model_config_connectivity_exception(): # Setup - with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check, \ - mock.patch("backend.services.model_health_service.ModelConnectStatusEnum") as mock_enum, \ - mock.patch("backend.services.model_health_service.ModelResponse", side_effect=ModelResponse) as mock_response: + with mock.patch("backend.services.model_health_service._perform_connectivity_check") as mock_connectivity_check: - mock_enum.UNAVAILABLE.value = "unavailable" mock_connectivity_check.side_effect = Exception("Unexpected error") model_config = { @@ -592,11 +552,8 @@ async def test_verify_model_config_connectivity_exception(): response = await verify_model_config_connectivity(model_config) # Assert - assert response.code == 500 - assert response.data["connectivity"] is False - assert response.data["connect_status"] == "unavailable" - assert response.data["error_code"] == "MODEL_VALIDATION_ERROR_UNKNOWN" - assert response.data["error_details"] == "Unexpected error" + assert response["connectivity"] is False + assert response["model_name"] == "gpt-4" @pytest.mark.asyncio @@ -647,10 +604,10 @@ async def test_embedding_dimension_check_multi_embedding_success(): @pytest.mark.asyncio async def test_embedding_dimension_check_unsupported_type(): - dimension = await _embedding_dimension_check( - "test-model", "unsupported", "http://test.com", "test-key" - ) - assert dimension == 0 + with pytest.raises(ValueError): + await _embedding_dimension_check( + "test-model", "unsupported", "http://test.com", "test-key" + ) @pytest.mark.asyncio @@ -705,7 +662,63 @@ async def test_embedding_dimension_check_wrapper_exception(): dimension = await embedding_dimension_check(model_config) assert dimension == 0 mock_get_name.assert_called_once_with(model_config) - mock_logger.warning.assert_called_once() + mock_logger.error.assert_called_once() + + +@pytest.mark.asyncio +async def test_embedding_dimension_check_multi_embedding_empty_response(): + """Test multi_embedding dimension check with empty response (covers line 48-50)""" + with mock.patch("backend.services.model_health_service.JinaEmbedding") as mock_embedding, \ + mock.patch("backend.services.model_health_service.logging") as mock_logging: + mock_embedding_instance = mock.MagicMock() + mock_embedding_instance.dimension_check = mock.AsyncMock( + return_value=[]) + mock_embedding.return_value = mock_embedding_instance + + dimension = await _embedding_dimension_check( + "test-multi-embedding", "multi_embedding", "http://test.com", "test-key" + ) + + assert dimension == 0 + mock_embedding.assert_called_once_with( + model_name="test-multi-embedding", + base_url="http://test.com", + api_key="test-key", + embedding_dim=0 + ) + # Verify warning was logged + mock_logging.warning.assert_called_once_with( + "Embedding dimension check for test-multi-embedding gets empty response" + ) + + +@pytest.mark.asyncio +async def test_embedding_dimension_check_wrapper_value_error(): + """Test embedding_dimension_check wrapper with ValueError (covers line 249-250)""" + with mock.patch("backend.services.model_health_service._embedding_dimension_check") as mock_internal_check, \ + mock.patch("backend.services.model_health_service.get_model_name_from_config") as mock_get_name, \ + mock.patch("backend.services.model_health_service.logger") as mock_logger: + mock_internal_check.side_effect = ValueError("Unsupported model type") + mock_get_name.return_value = "test-model" + model_config = { + "model_repo": "test", + "model_name": "test-model", + "model_type": "unsupported", + "base_url": "https://api.test.com", + "api_key": "test-key" + } + + dimension = await embedding_dimension_check(model_config) + + assert dimension == 0 + mock_get_name.assert_called_once_with(model_config) + mock_internal_check.assert_called_once_with( + "test-model", "unsupported", "https://api.test.com", "test-key" + ) + # Verify error was logged with the specific ValueError message + mock_logger.error.assert_called_once_with( + "Error checking embedding dimension: Unsupported model type" + ) @pytest.mark.asyncio diff --git a/test/backend/services/test_model_management_service.py b/test/backend/services/test_model_management_service.py new file mode 100644 index 000000000..c0bf9ee2e --- /dev/null +++ b/test/backend/services/test_model_management_service.py @@ -0,0 +1,415 @@ +import os +import sys +import types +import pytest +from unittest import mock + +# Add backend to Python path for imports +current_dir = os.path.dirname(os.path.abspath(__file__)) +backend_dir = os.path.abspath(os.path.join(current_dir, "../../../backend")) +if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + + +# Stub external modules required by consts.model before importing services +if "nexent" not in sys.modules: + sys.modules["nexent"] = mock.MagicMock() +if "nexent.core" not in sys.modules: + sys.modules["nexent.core"] = mock.MagicMock() +if "nexent.core.agents" not in sys.modules: + sys.modules["nexent.core.agents"] = mock.MagicMock() +if "nexent.core.agents.agent_model" not in sys.modules: + agent_model_mod = types.ModuleType("nexent.core.agents.agent_model") + + class ToolConfig: # minimal stub + pass + + agent_model_mod.ToolConfig = ToolConfig + sys.modules["nexent.core.agents.agent_model"] = agent_model_mod + +# Stub boto3 used by backend.database.client +if "boto3" not in sys.modules: + sys.modules["boto3"] = mock.MagicMock() + +# Stub consts.model to avoid deep dependencies +consts_model_mod = types.ModuleType("consts.model") +class _EnumItem: + def __init__(self, value: str): + self.value = value + +class _ModelConnectStatusEnum: + OPERATIONAL = _EnumItem("operational") + NOT_DETECTED = _EnumItem("not_detected") + DETECTING = _EnumItem("detecting") + UNAVAILABLE = _EnumItem("unavailable") + + @staticmethod + def get_value(status): + return status or _ModelConnectStatusEnum.NOT_DETECTED.value +consts_model_mod.ModelConnectStatusEnum = _ModelConnectStatusEnum +sys.modules["consts.model"] = consts_model_mod +if "consts" not in sys.modules: + sys.modules["consts"] = types.ModuleType("consts") + +# Stub consts.provider used by service +consts_provider_mod = types.ModuleType("consts.provider") +class _ProviderEnum: + SILICON = _EnumItem("silicon") +consts_provider_mod.ProviderEnum = _ProviderEnum +consts_provider_mod.SILICON_BASE_URL = "http://silicon.test" +sys.modules["consts.provider"] = consts_provider_mod + +# Stub services.model_provider_service used by service +services_provider_mod = types.ModuleType("services.model_provider_service") +async def _prepare_model_dict(**kwargs): + return {} +def _merge_existing_model_tokens(model_list, tenant_id, provider, model_type): + return model_list +async def _get_provider_models(model_data): + return [] +services_provider_mod.prepare_model_dict = _prepare_model_dict +services_provider_mod.merge_existing_model_tokens = _merge_existing_model_tokens +services_provider_mod.get_provider_models = _get_provider_models +sys.modules["services.model_provider_service"] = services_provider_mod + +# Stub services.model_health_service used by service +services_health_mod = types.ModuleType("services.model_health_service") +async def _embedding_dimension_check(model_config): + return 0 +services_health_mod.embedding_dimension_check = _embedding_dimension_check +sys.modules["services.model_health_service"] = services_health_mod + +# Stub utils.model_name_utils used by service +utils_name_mod = types.ModuleType("utils.model_name_utils") +def _add_repo_to_name(model_repo, model_name): + return f"{model_repo}/{model_name}" if model_repo else model_name +def _split_display_name(model_name: str): + return model_name.split("/")[-1] +def _split_repo_name(model_name: str): + parts = model_name.split("/", 1) + return (parts[0], parts[1]) if len(parts) > 1 else ("", parts[0]) +def _sort_models_by_id(model_list): + if isinstance(model_list, list): + model_list.sort(key=lambda m: str((m.get("id") if isinstance(m, dict) else m) or "")[:1].lower()) + return model_list +utils_name_mod.add_repo_to_name = _add_repo_to_name +utils_name_mod.split_display_name = _split_display_name +utils_name_mod.split_repo_name = _split_repo_name +utils_name_mod.sort_models_by_id = _sort_models_by_id +sys.modules["utils.model_name_utils"] = utils_name_mod + +# Stub database.model_management_db to avoid importing heavy DB client +database_mod = types.ModuleType("database") +db_mm_mod = types.ModuleType("database.model_management_db") +def _noop(*args, **kwargs): + return None +def _get_model_records(*args, **kwargs): + return [] +def _get_models_by_tenant_factory_type(*args, **kwargs): + return [] +db_mm_mod.create_model_record = _noop +db_mm_mod.delete_model_record = _noop +db_mm_mod.get_model_by_display_name = _noop +db_mm_mod.get_model_records = _get_model_records +db_mm_mod.get_models_by_tenant_factory_type = _get_models_by_tenant_factory_type +db_mm_mod.update_model_record = _noop +sys.modules["database"] = database_mod +sys.modules["database.model_management_db"] = db_mm_mod + + +@pytest.mark.asyncio +async def test_create_model_for_tenant_success_llm(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_model_by_display_name", return_value=None) as mock_get_by_display, \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("huggingface", "llama")): + + user_id = "u1" + tenant_id = "t1" + model_data = { + "model_name": "huggingface/llama", + "display_name": None, + "base_url": "http://localhost:8000", + "model_type": "llm", + } + + await svc.create_model_for_tenant(user_id, tenant_id, model_data) + + mock_get_by_display.assert_called_once_with("llama", tenant_id) + # create_model_record called once for non-multimodal + assert mock_create.call_count == 1 + + +@pytest.mark.asyncio +async def test_create_model_for_tenant_conflict_raises(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_model_by_display_name", return_value={"model_id": "exists"}): + user_id = "u1" + tenant_id = "t1" + model_data = { + "model_name": "huggingface/llama", + "display_name": "dup", + "base_url": "http://localhost:8000", + "model_type": "llm", + } + + with pytest.raises(Exception) as exc: + await svc.create_model_for_tenant(user_id, tenant_id, model_data) + assert "Failed to create model" in str(exc.value) + + +@pytest.mark.asyncio +async def test_create_model_for_tenant_multi_embedding_creates_three_records(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_model_by_display_name", return_value=None), \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("openai", "clip")): + + user_id = "u1" + tenant_id = "t1" + model_data = { + "model_name": "openai/clip", + "display_name": None, + "base_url": "https://api.openai.com", + "model_type": "multi_embedding", + } + + await svc.create_model_for_tenant(user_id, tenant_id, model_data) + # Per current implementation, it creates multi_embedding, embedding variant, and then one more general create + assert mock_create.call_count == 3 + + +@pytest.mark.asyncio +async def test_create_model_for_tenant_embedding_sets_dimension(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_model_by_display_name", return_value=None), \ + mock.patch.object(svc, "embedding_dimension_check", new=mock.AsyncMock(return_value=1536)) as mock_dim, \ + mock.patch.object(svc, "create_model_record") as mock_create, \ + mock.patch.object(svc, "split_repo_name", return_value=("openai", "text-embedding-ada-002")): + + user_id = "u1" + tenant_id = "t1" + model_data = { + "model_name": "openai/text-embedding-ada-002", + "display_name": None, + "base_url": "https://api.openai.com", + "model_type": "embedding", + } + + await svc.create_model_for_tenant(user_id, tenant_id, model_data) + + mock_dim.assert_awaited() + # Ensure we created exactly one record (non-multimodal) + assert mock_create.call_count == 1 + + +@pytest.mark.asyncio +async def test_create_provider_models_for_tenant_success(): + from backend.services import model_management_service as svc + + req = {"provider": "silicon", "model_type": "llm"} + models = [{"id": "silicon/a"}, {"id": "silicon/b"}] + + with mock.patch.object(svc, "get_provider_models", new=mock.AsyncMock(return_value=models)) as mock_get, \ + mock.patch.object(svc, "merge_existing_model_tokens", return_value=models) as mock_merge, \ + mock.patch.object(svc, "sort_models_by_id", side_effect=lambda m: m) as mock_sort: + + out = await svc.create_provider_models_for_tenant("t1", req) + assert out == models + mock_get.assert_awaited_once() + mock_merge.assert_called_once() + mock_sort.assert_called_once() + + +@pytest.mark.asyncio +async def test_create_provider_models_for_tenant_exception(): + from backend.services import model_management_service as svc + + req = {"provider": "silicon", "model_type": "llm"} + with mock.patch.object(svc, "get_provider_models", new=mock.AsyncMock(side_effect=Exception("boom"))): + with pytest.raises(Exception) as exc: + await svc.create_provider_models_for_tenant("t1", req) + assert "Failed to create provider models" in str(exc.value) + + +@pytest.mark.asyncio +async def test_batch_create_models_for_tenant_flow(): + from backend.services import model_management_service as svc + + batch_payload = { + "provider": "silicon", + "type": "llm", + "models": [ + {"id": "silicon/keep", "max_tokens": 4096}, + {"id": "silicon/new", "max_tokens": 8192}, + ], + "api_key": "k", + } + + existing = [ + {"model_id": "del-id", "model_repo": "silicon", "model_name": "delete"}, + {"model_id": "keep-id", "model_repo": "silicon", "model_name": "keep"}, + ] + + def get_by_display(display_name, tenant_id): + if display_name == "silicon/keep": + return {"model_id": "keep-id", "max_tokens": 1024} + return None + + with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=existing) as mock_get_existing, \ + mock.patch.object(svc, "delete_model_record") as mock_delete, \ + mock.patch.object(svc, "get_model_by_display_name", side_effect=get_by_display) as mock_get_by_display, \ + mock.patch.object(svc, "update_model_record") as mock_update, \ + mock.patch.object(svc, "prepare_model_dict", new=mock.AsyncMock(return_value={"prepared": True})) as mock_prep, \ + mock.patch.object(svc, "create_model_record") as mock_create: + + await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) + + mock_get_existing.assert_called_once_with("t1", "silicon", "llm") + mock_delete.assert_called_once_with("del-id", "u1", "t1") + mock_get_by_display.assert_any_call("silicon/keep", "t1") + mock_update.assert_called_once_with("keep-id", {"max_tokens": 4096}, "u1") + mock_prep.assert_awaited() + mock_create.assert_called_once() + + +@pytest.mark.asyncio +async def test_batch_create_models_for_tenant_exception(): + from backend.services import model_management_service as svc + + batch_payload = {"provider": "other", "type": "llm", "models": [{"id": "x"}], "api_key": "k"} + + with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=[]), \ + mock.patch.object(svc, "prepare_model_dict", new=mock.AsyncMock(side_effect=Exception("prep failed"))): + with pytest.raises(Exception) as exc: + await svc.batch_create_models_for_tenant("u1", "t1", batch_payload) + assert "Failed to batch create models" in str(exc.value) + + +async def test_list_provider_models_for_tenant_success(): + from backend.services import model_management_service as svc + + existing = [ + {"model_repo": "huggingface", "model_name": "llama"}, + {"model_repo": "openai", "model_name": "clip"}, + ] + with mock.patch.object(svc, "get_models_by_tenant_factory_type", return_value=existing): + out = await svc.list_provider_models_for_tenant("t1", "huggingface", "llm") + assert out[0]["id"] == "huggingface/llama" + assert out[1]["id"] == "openai/clip" + + +async def test_list_provider_models_for_tenant_exception(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_models_by_tenant_factory_type", side_effect=Exception("db")): + with pytest.raises(Exception) as exc: + await svc.list_provider_models_for_tenant("t1", "p", "llm") + assert "Failed to list provider models" in str(exc.value) + + +async def test_update_single_model_for_tenant_success(): + from backend.services import model_management_service as svc + + model = {"model_id": "m1", "display_name": "name"} + with mock.patch.object(svc, "get_model_by_display_name", return_value=None) as mock_get, \ + mock.patch.object(svc, "update_model_record") as mock_update: + await svc.update_single_model_for_tenant("u1", "t1", model) + mock_get.assert_called_once_with("name", "t1") + mock_update.assert_called_once_with("m1", model, "u1") + + +async def test_update_single_model_for_tenant_conflict(): + from backend.services import model_management_service as svc + + model = {"model_id": "m1", "display_name": "name"} + with mock.patch.object(svc, "get_model_by_display_name", return_value={"model_id": "other"}): + with pytest.raises(Exception) as exc: + await svc.update_single_model_for_tenant("u1", "t1", model) + assert "Failed to update model" in str(exc.value) + + +async def test_batch_update_models_for_tenant_success(): + from backend.services import model_management_service as svc + + models = [{"model_id": "a"}, {"model_id": "b"}] + with mock.patch.object(svc, "update_model_record") as mock_update: + await svc.batch_update_models_for_tenant("u1", "t1", models) + assert mock_update.call_count == 2 + mock_update.assert_any_call("a", models[0], "u1") + mock_update.assert_any_call("b", models[1], "u1") + + +async def test_batch_update_models_for_tenant_exception(): + from backend.services import model_management_service as svc + + models = [{"model_id": "a"}] + with mock.patch.object(svc, "update_model_record", side_effect=Exception("oops")): + with pytest.raises(Exception) as exc: + await svc.batch_update_models_for_tenant("u1", "t1", models) + assert "Failed to batch update models" in str(exc.value) + + +async def test_delete_model_for_tenant_not_found(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_model_by_display_name", return_value=None): + with pytest.raises(Exception) as exc: + await svc.delete_model_for_tenant("u1", "t1", "missing") + assert "Failed to delete model" in str(exc.value) + + +async def test_delete_model_for_tenant_embedding_deletes_both(): + from backend.services import model_management_service as svc + + # Call sequence: initial -> embedding -> multi_embedding + side_effect = [ + {"model_id": "id-emb", "model_type": "embedding"}, + {"model_id": "id-emb", "model_type": "embedding"}, + {"model_id": "id-multi", "model_type": "multi_embedding"}, + ] + with mock.patch.object(svc, "get_model_by_display_name", side_effect=side_effect) as mock_get, \ + mock.patch.object(svc, "delete_model_record") as mock_delete: + await svc.delete_model_for_tenant("u1", "t1", "name") + assert mock_delete.call_count == 2 + mock_get.assert_called() + + +async def test_delete_model_for_tenant_non_embedding(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_model_by_display_name", return_value={"model_id": "id", "model_type": "llm"}), \ + mock.patch.object(svc, "delete_model_record") as mock_delete: + await svc.delete_model_for_tenant("u1", "t1", "name") + mock_delete.assert_called_once_with("id", "u1", "t1") + + +async def test_list_models_for_tenant_success(): + from backend.services import model_management_service as svc + + records = [ + {"model_repo": "huggingface", "model_name": "llama", "connect_status": "operational"}, + {"model_repo": "openai", "model_name": "clip", "connect_status": None}, + ] + with mock.patch.object(svc, "get_model_records", return_value=records), \ + mock.patch.object(svc, "add_repo_to_name", side_effect=lambda model_repo, model_name: f"{model_repo}/{model_name}" if model_repo else model_name), \ + mock.patch.object(svc.ModelConnectStatusEnum, "get_value", side_effect=lambda s: s or "not_detected"): + out = await svc.list_models_for_tenant("t1") + assert out[0]["model_name"] == "huggingface/llama" + assert out[1]["model_name"] == "openai/clip" + assert out[1]["connect_status"] == "not_detected" + + +async def test_list_models_for_tenant_exception(): + from backend.services import model_management_service as svc + + with mock.patch.object(svc, "get_model_records", side_effect=Exception("db")): + with pytest.raises(Exception) as exc: + await svc.list_models_for_tenant("t1") + assert "Failed to retrieve model list" in str(exc.value) + + diff --git a/test/backend/utils/test_auth_utils.py b/test/backend/utils/test_auth_utils.py index f80b9c170..0e26dee22 100644 --- a/test/backend/utils/test_auth_utils.py +++ b/test/backend/utils/test_auth_utils.py @@ -143,8 +143,9 @@ def test_validate_aksk_authentication_invalid(monkeypatch): def test_generate_test_jwt_and_get_expiry_seconds(monkeypatch): token = au.generate_test_jwt("user-1", expires_in=1234) - # ensure not in speed mode for this test + # ensure not in speed mode and no DEBUG_JWT_EXPIRE_SECONDS was set for this test monkeypatch.setattr(au, "IS_SPEED_MODE", False) + monkeypatch.setattr(au, "DEBUG_JWT_EXPIRE_SECONDS", 0) seconds = au.get_jwt_expiry_seconds(token) assert seconds == 1234