From 0ae5961e1ccf03a5e2cc43d3579885efadc7f416 Mon Sep 17 00:00:00 2001 From: Lynn Date: Wed, 8 Jul 2026 09:47:29 +0800 Subject: [PATCH] Feat: v0.27.0 model provider (#16604) --- api/apps/restful_apis/memory_api.py | 37 +- api/apps/restful_apis/provider_api.py | 223 ++++- api/apps/services/memory_api_service.py | 14 +- api/apps/services/models_api_service.py | 145 +-- api/apps/services/provider_api_service.py | 516 ++++++++--- api/db/db_models.py | 38 +- .../joint_services/memory_message_service.py | 34 +- api/db/joint_services/tenant_model_service.py | 207 +++-- api/db/services/dialog_service.py | 62 +- api/db/services/memory_service.py | 5 +- api/db/services/task_service.py | 2 + api/db/services/tenant_model_service.py | 57 +- api/utils/model_utils.py | 33 + api/utils/validation_utils.py | 4 +- common/constants.py | 16 + docker/entrypoint.sh | 9 +- docker/launch_backend_service.sh | 9 +- rag/flow/tokenizer/tokenizer.py | 10 +- rag/graphrag/general/smoke.py | 10 +- rag/graphrag/light/smoke.py | 10 +- rag/graphrag/search.py | 10 +- rag/llm/ocr_model.py | 2 +- rag/svr/task_executor.py | 10 +- .../dataflow_service.py | 14 +- .../task_executor_refactor/task_context.py | 18 + .../task_executor_refactor/task_handler.py | 21 +- test/testcases/restful_api/test_datasets.py | 4 +- .../test_dify_retrieval_routes_unit.py | 4 +- .../test_dify_retrieval_routes_unit.py | 4 +- .../test_doc_sdk_routes_unit.py | 10 +- .../test_session_sdk_routes_unit.py | 2 +- .../test_chunk_app/test_chunk_routes_unit.py | 8 +- ...ls_api_service_list_tenant_added_models.py | 28 +- .../test_dialog_service_final_answer.py | 1 + ...t_dialog_service_use_sql_source_columns.py | 1 + tools/scripts/mysql_migration.py | 790 ++++++++++++++++ tools/scripts/run_migrations.sh | 38 + web/src/hooks/use-llm-request.tsx | 176 +++- web/src/interfaces/database/llm.ts | 36 +- web/src/interfaces/request/llm.ts | 45 +- web/src/locales/en.ts | 17 + web/src/locales/zh.ts | 15 + .../data-source/constant/s3-constant.tsx | 2 +- .../setting-model/components/llm-header.tsx | 34 - .../setting-model/components/used-model.tsx | 401 --------- .../{constant.ts => constants.ts} | 16 + .../user-setting/setting-model/hooks.tsx | 124 +-- .../user-setting/setting-model/index.less | 16 + .../user-setting/setting-model/index.tsx | 611 +++++-------- .../add-custom-model-dialog.tsx | 16 + .../available-models.tsx} | 18 +- .../instance-card/bedrock-instance-card.tsx | 783 ++++++++++++++++ .../components/draft-mode-card.tsx | 97 ++ .../components/instance-name-section.tsx | 99 ++ .../components/saved-mode-card.tsx | 148 +++ .../setting-model/instance-card/hooks.tsx | 816 +++++++++++++++++ .../setting-model/instance-card/interface.ts | 119 +++ .../models-section/components/model-row.tsx | 81 ++ .../components/model-type-badges.tsx | 90 ++ .../components/model-verify-button.tsx | 65 ++ .../components/tag-filter-button.tsx | 42 + .../instance-card/models-section/hooks.ts | 601 +++++++++++++ .../instance-card/models-section/index.tsx | 311 +++++++ .../instance-card/models-section/interface.ts | 120 +++ .../instance-card/provider-instance-card.tsx | 237 +++++ .../instance-card/somark-instance-card.tsx | 845 ++++++++++++++++++ .../use-custom-model-fields.tsx | 16 + .../verify-button.tsx} | 31 +- .../layout/provider-header-bar.tsx | 62 ++ .../setting-model/layout/sidebar.tsx | 153 ++++ .../{components => layout}/system-setting.tsx | 26 +- .../modal/bedrock-modal/index.tsx | 379 -------- .../components/addable-toggle-list.tsx | 162 ---- .../provider-modal/field-config/index.ts | 5 - .../modal/provider-modal/hooks/index.ts | 4 - .../modal/provider-modal/index.tsx | 318 ------- .../modal/somark-modal/index.tsx | 366 -------- .../setting-model/payload-utils.ts | 16 + .../constants.ts | 16 + .../field-config/generic-api-key-config.ts | 16 + .../field-config/get-provider-config.ts | 16 + .../provider-schema/field-config/index.ts | 21 + .../field-config/local-llm-configs.ts | 80 +- .../field-config/provider-config-map.ts | 342 +------ .../field-config/utils.ts | 16 + .../provider-schema/hooks/index.ts | 20 + .../hooks/use-list-models-options.tsx | 16 + .../hooks/use-list-models-picker.ts | 16 + .../hooks/use-provider-fields.tsx | 25 +- .../hooks/use-provider-modal-actions.ts | 16 + .../types.ts | 16 + web/src/pages/user-setting/sidebar/index.tsx | 2 +- web/src/services/llm-service.ts | 15 + web/src/utils/api.ts | 25 + 94 files changed, 7539 insertions(+), 3044 deletions(-) create mode 100644 api/utils/model_utils.py create mode 100755 tools/scripts/run_migrations.sh delete mode 100644 web/src/pages/user-setting/setting-model/components/llm-header.tsx delete mode 100644 web/src/pages/user-setting/setting-model/components/used-model.tsx rename web/src/pages/user-setting/setting-model/{constant.ts => constants.ts} (51%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal/components => instance-card}/add-custom-model-dialog.tsx (92%) rename web/src/pages/user-setting/setting-model/{components/un-add-model.tsx => instance-card/available-models.tsx} (90%) create mode 100644 web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/components/saved-mode-card.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/hooks.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/interface.ts create mode 100644 web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-type-badges.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-verify-button.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/models-section/components/tag-filter-button.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts create mode 100644 web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts create mode 100644 web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx create mode 100644 web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx rename web/src/pages/user-setting/setting-model/{modal/provider-modal/components => instance-card}/use-custom-model-fields.tsx (76%) rename web/src/pages/user-setting/setting-model/{modal/verify-button/index.tsx => instance-card/verify-button.tsx} (78%) create mode 100644 web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx create mode 100644 web/src/pages/user-setting/setting-model/layout/sidebar.tsx rename web/src/pages/user-setting/setting-model/{components => layout}/system-setting.tsx (82%) delete mode 100644 web/src/pages/user-setting/setting-model/modal/bedrock-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/components/addable-toggle-list.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/field-config/index.ts delete mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/hooks/index.ts delete mode 100644 web/src/pages/user-setting/setting-model/modal/provider-modal/index.tsx delete mode 100644 web/src/pages/user-setting/setting-model/modal/somark-modal/index.tsx rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/constants.ts (71%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/field-config/generic-api-key-config.ts (79%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/field-config/get-provider-config.ts (60%) create mode 100644 web/src/pages/user-setting/setting-model/provider-schema/field-config/index.ts rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/field-config/local-llm-configs.ts (79%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/field-config/provider-config-map.ts (66%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/field-config/utils.ts (73%) create mode 100644 web/src/pages/user-setting/setting-model/provider-schema/hooks/index.ts rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/hooks/use-list-models-options.tsx (83%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/hooks/use-list-models-picker.ts (94%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/hooks/use-provider-fields.tsx (93%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/hooks/use-provider-modal-actions.ts (90%) rename web/src/pages/user-setting/setting-model/{modal/provider-modal => provider-schema}/types.ts (91%) diff --git a/api/apps/restful_apis/memory_api.py b/api/apps/restful_apis/memory_api.py index 763befeb3d..e0b86cdf6f 100644 --- a/api/apps/restful_apis/memory_api.py +++ b/api/apps/restful_apis/memory_api.py @@ -23,6 +23,7 @@ from common.exceptions import ArgumentException, NotFoundException from api.apps import login_required, current_user from api.utils.api_utils import validate_request, get_request_json, get_error_argument_result, get_json_result from api.apps.services import memory_api_service +from api.db.joint_services.tenant_model_service import ensure_tenant_model_ids_for_params from api.utils.pagination_utils import validate_rest_api_page_size @@ -35,7 +36,15 @@ async def create_memory(): req = await get_request_json() t_parsed = time.perf_counter() if timing_enabled else None try: - memory_info = {"name": req["name"], "memory_type": req["memory_type"], "embd_id": req["embd_id"], "llm_id": req["llm_id"]} + # Resolve tenant_model IDs from model names + tenant_id = current_user.id + memory_info = { + "name": req["name"], + "memory_type": req["memory_type"], + "embd_id": req["embd_id"], + "llm_id": req["llm_id"] + } + ensure_tenant_model_ids_for_params(tenant_id, memory_info) success, res = await memory_api_service.create_memory(memory_info) if timing_enabled: logging.info( @@ -79,26 +88,12 @@ async def create_memory(): @login_required async def update_memory(memory_id): req = await get_request_json() - new_settings = { - k: req[k] - for k in [ - "name", - "permissions", - "llm_id", - "embd_id", - "memory_type", - "memory_size", - "forgetting_policy", - "temperature", - "avatar", - "description", - "system_prompt", - "user_prompt", - "tenant_llm_id", - "tenant_embd_id", - ] - if k in req - } + # Resolve tenant_model IDs from model names when name is provided but id is not + ensure_tenant_model_ids_for_params(current_user.id, req) + new_settings = {k: req[k] for k in [ + "name", "permissions", "llm_id", "embd_id", "memory_type", "memory_size", "forgetting_policy", "temperature", + "avatar", "description", "system_prompt", "user_prompt", "tenant_llm_id", "tenant_embd_id" + ] if k in req} try: success, res = await memory_api_service.update_memory(memory_id, new_settings) if success: diff --git a/api/apps/restful_apis/provider_api.py b/api/apps/restful_apis/provider_api.py index dea0c03b49..6010e144c7 100644 --- a/api/apps/restful_apis/provider_api.py +++ b/api/apps/restful_apis/provider_api.py @@ -17,7 +17,7 @@ import logging from quart import request -from api.apps import login_required +from api.apps import login_required, current_user from api.utils.api_utils import ( add_tenant_id_to_kwargs, get_error_argument_result, @@ -316,6 +316,9 @@ async def create_provider_instance(tenant_id: str = None, provider_id_or_name: s required: - instance_name - api_key + - base_url + - region + - model_info properties: instance_name: type: string @@ -336,10 +339,24 @@ async def create_provider_instance(tenant_id: str = None, provider_id_or_name: s type: object """ data = await request.get_json() + if not provider_id_or_name: + return get_error_argument_result(message="provider_id_or_name is required") if not data or "instance_name" not in data: return get_error_argument_result(message="instance_name is required") instance_name = data["instance_name"] + # data only contains instance_name — no other fields needed + if set(data.keys()) == {"instance_name"}: + try: + success, msg = await provider_api_service.create_name_only_provider_instance(tenant_id, provider_id_or_name, instance_name) + if success: + return get_result(message=msg) + else: + return get_error_data_result(message=msg) + except Exception as e: + logging.exception(e) + return get_error_data_result(message="Internal server error") + api_key = data.get("api_key", "") base_url = data.get("base_url", "") region = data.get("region", "") @@ -397,7 +414,10 @@ async def verify_provider_api_key(provider_id_or_name: str = None): description: Region. model_info: type: object - description: Model info. + description: Model info. optional + instance_id: + type: string + description: Instance ID. optional responses: 200: description: Instance created successfully. @@ -405,6 +425,8 @@ async def verify_provider_api_key(provider_id_or_name: str = None): type: object """ data = await request.get_json() + if not provider_id_or_name: + return get_error_argument_result(message="provider_id_or_name is required") if not data or ("api_key" not in data and provider_id_or_name != "VLLM"): return get_error_argument_result(message="api_key is required") @@ -414,8 +436,16 @@ async def verify_provider_api_key(provider_id_or_name: str = None): model_info = data.get("model_info", []) try: - success, msg = await provider_api_service.verify_api_key(provider_id_or_name, api_key, base_url, region, model_info) + success, msg, model_verify_result = await provider_api_service.verify_api_key(provider_id_or_name, api_key, base_url, region, model_info) if success: + if data.get("instance_id"): + # if instance_id is provided, update the model verify result + instance_id = data["instance_id"] + try: + for model, verify_result in model_verify_result.items(): + provider_api_service.update_model(current_user.id, provider_id_or_name, instance_id, model, {"verify": verify_result}) + except Exception as e: + logging.exception(e) return get_result(message=msg) else: return get_error_data_result(message=msg) @@ -512,6 +542,117 @@ def show_provider_instance(tenant_id: str = None, provider_id_or_name: str = Non return get_error_data_result(message="Internal server error") +@manager.route("/providers//instances/", methods=["PUT"]) # noqa: F821 +@login_required +@add_tenant_id_to_kwargs +async def update_provider_instance(tenant_id: str = None, provider_id_or_name: str = None, instance_id_or_name: str = None): + """ + Update a provider instance. + --- + tags: + - Providers + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: provider_id_or_name + type: string + required: true + description: Provider ID or name. + - in: path + name: instance_id_or_name + type: string + required: true + description: Instance ID or name. + - in: header + name: Authorization + type: string + required: true + description: Bearer token for authentication. + - in: body + name: body + description: Instance update parameters. + required: true + schema: + type: object + required: + - instance_name + - api_key + - base_url + - region + - model_info + properties: + instance_name: + type: string + description: Instance name. + api_key: + type: string + description: API key. + base_url: + type: string + description: Base URL. + region: + type: string + description: Region. + model_info: + type: array + description: List of models to configure for this instance. + items: + type: object + properties: + model_type: + type: array + description: Model types. + model_name: + type: string + description: Model name. + max_tokens: + type: integer + description: Max tokens. + extra: + type: object + description: Extra model info (e.g. is_tools). + verify: + type: boolean + description: Verify api_key and base_url, default true + responses: + 200: + description: Instance updated successfully. + schema: + type: object + """ + data = await request.get_json() + if not provider_id_or_name: + return get_error_argument_result(message="provider_id_or_name is required") + if not instance_id_or_name: + return get_error_argument_result(message="instance_id_or_name is required") + if not data: + return get_error_argument_result(message="Request body is required") + required_keys = ["instance_name", "api_key", "base_url", "model_info"] + missing = [k for k in required_keys if k not in data] + if missing: + return get_error_argument_result(message=f"Missing required fields: {', '.join(missing)}") + + instance_name = data["instance_name"] + api_key = data["api_key"] + base_url = data["base_url"] + region = data.get("region", "default") + model_info = data["model_info"] + verify = data.get("verify", True) + + try: + success, msg = await provider_api_service.update_provider_instance( + tenant_id, provider_id_or_name, instance_id_or_name, instance_name, api_key, base_url, region, model_info, verify + ) + if success: + return get_result(message=msg) + else: + return get_error_data_result(message=msg) + except Exception as e: + logging.exception(e) + return get_error_data_result(message="Internal server error") + + @manager.route("/providers//instances", methods=["DELETE"]) # noqa: F821 @login_required @add_tenant_id_to_kwargs @@ -761,12 +902,72 @@ async def add_model_to_instance(tenant_id: str, provider_id_or_name: str, instan return get_error_data_result(message="Internal server error") +@manager.route("/providers//instances//models", methods=["DELETE"]) # noqa: F821 +@login_required +@add_tenant_id_to_kwargs +async def delete_models_from_instance(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str): + """ + Delete models from an instance. + --- + tags: + - Providers + security: + - ApiKeyAuth: [] + parameters: + - in: path + name: provider_id_or_name + type: string + required: true + description: Provider ID or name. + - in: path + name: instance_id_or_name + type: string + required: true + description: Instance ID or name. + - in: header + name: Authorization + type: string + required: true + description: Bearer token for authentication. + - in: body + name: body + description: Model details. + required: true + schema: + type: object + required: + - model_name + - model_type + properties: + model_name: + type: list of string + description: Model name. + responses: + 200: + description: Model deleted successfully. + """ + data = await request.get_json() + if not data or "model_name" not in data: + return get_error_argument_result(message="model_name is required") + model_name = data["model_name"] + try: + success, result = await provider_api_service.delete_models_from_instance(tenant_id, provider_id_or_name, instance_id_or_name, model_name) + if success: + return get_result(message=result) + else: + return get_error_data_result(message=result) + except Exception as e: + logging.exception(e) + return get_error_data_result(message="Internal server error") + + + @manager.route("/providers//instances//models/", methods=["PATCH"]) # noqa: F821 @login_required @add_tenant_id_to_kwargs -async def enable_or_disable_model(tenant_id: str = None, provider_id_or_name: str = None, instance_id_or_name: str = None, model_name: str = None): +async def alter_model(tenant_id: str = None, provider_id_or_name: str = None, instance_id_or_name: str = None, model_name: str = None): """ - Enable or disable a model. + Enable or disable a model, or update max_tokens --- tags: - Providers @@ -813,15 +1014,17 @@ async def enable_or_disable_model(tenant_id: str = None, provider_id_or_name: st type: object """ data = await request.get_json() - if not data or "status" not in data: - return get_error_argument_result(message="status is required") + if not data or ("status" not in data and "max_tokens" not in data): + return get_error_argument_result(message="status or max_tokens required.") - status = data["status"] - if status not in ("active", "inactive"): + update_dict = {k: data[k] for k in ["status", "max_tokens", "model_type", "extra"] if k in data} + if update_dict.get("status") and update_dict["status"] not in ("active", "inactive"): return get_error_argument_result(message="status must be 'active' or 'inactive'") try: - success, msg = provider_api_service.update_model_status(tenant_id, provider_id_or_name, instance_id_or_name, model_name, status) + success, msg = provider_api_service.update_model( + tenant_id, provider_id_or_name, instance_id_or_name, model_name, update_dict + ) if success: return get_result(message=msg) else: diff --git a/api/apps/services/memory_api_service.py b/api/apps/services/memory_api_service.py index a12eb15946..ba8fac6a2c 100644 --- a/api/apps/services/memory_api_service.py +++ b/api/apps/services/memory_api_service.py @@ -79,8 +79,8 @@ async def create_memory(memory_info: dict): "memory_type": list[str], "embd_id": str, "llm_id": str, - "tenant_embd_id": str, - "tenant_llm_id": str + "tenant_embd_id": str | None, + "tenant_llm_id": str | None } """ # check name length @@ -98,7 +98,15 @@ async def create_memory(memory_info: dict): if invalid_type: raise ArgumentException(f"Memory type '{invalid_type}' is not supported.") memory_type = list(memory_type) - success, res = MemoryService.create_memory(tenant_id=current_user.id, name=memory_name, memory_type=memory_type, embd_id=memory_info["embd_id"], llm_id=memory_info["llm_id"]) + success, res = MemoryService.create_memory( + tenant_id=current_user.id, + name=memory_name, + memory_type=memory_type, + embd_id=memory_info["embd_id"], + llm_id=memory_info["llm_id"], + tenant_embd_id=memory_info.get("tenant_embd_id"), + tenant_llm_id=memory_info.get("tenant_llm_id"), + ) if success: return True, format_ret_data_from_memory(res) else: diff --git a/api/apps/services/models_api_service.py b/api/apps/services/models_api_service.py index 8d78ec89e1..5ebcfdb485 100644 --- a/api/apps/services/models_api_service.py +++ b/api/apps/services/models_api_service.py @@ -21,6 +21,7 @@ from api.db.services.tenant_model_instance_service import TenantModelInstanceSer from api.db.services.tenant_model_provider_service import TenantModelProviderService from api.db.services.tenant_model_service import TenantModelService from api.db.services.user_service import TenantService +from api.utils.model_utils import get_model_type_human, calculate_model_type from common.constants import ActiveStatusEnum, LLMType from common.settings import FACTORY_LLM_INFOS @@ -120,37 +121,13 @@ def _get_model_info(tenant_id: str, default_model: str, model_type: str): logging.warning(f"Instance '{instance_name}' not found for provider '{provider_name}'") return None - # Check if model is enabled (no TenantModel record or status != inactive means enabled) - model_entity = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type, model_name) - enable = model_entity is None or model_entity.status == ActiveStatusEnum.ACTIVE.value - - if not enable: + model_record = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, model_name) + if not model_record: + logging.warning(f"Model '{model_name}' not found for provider '{provider_name}' and instance '{instance_name}'") return None - if model_entity: - return { - "model_provider": provider_name, - "model_instance": instance_name, - "model_name": model_name, - "model_type": model_type, - "enable": enable, - } - - # Check if model is in the LLM factory info - factory_info = [f for f in (FACTORY_LLM_INFOS or []) if f["name"] == provider_name] - if not factory_info: - logging.warning(f"Provider '{provider_name}' not found in factory info") - return None - - llms = factory_info[0].get("llm", []) - target_llm = [llm for llm in llms if llm["llm_name"] == model_name] - if not target_llm: - logging.warning(f"Model '{model_name}' not found for provider '{provider_name}'") - return None - - # Check if the model_type matches - if model_type not in _factory_model_types(target_llm[0]): - logging.warning(f"Model '{model_name}' isn't a {model_type} model") + if not model_record.status == ActiveStatusEnum.ACTIVE.value: + logging.warning(f"Model '{model_name}' is disabled") return None return { @@ -158,7 +135,7 @@ def _get_model_info(tenant_id: str, default_model: str, model_type: str): "model_instance": instance_name, "model_name": model_name, "model_type": model_type, - "enable": enable, + "enable": True } @@ -200,21 +177,11 @@ def _check_model_available(tenant_id: str, provider_name: str, instance_name: st return False, f"Provider '{provider_name}' not found in factory info" model_type = MODEL_TAG_TO_TYPE.get(model_type, model_type) # Check if model is disabled - model_entity = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name(provider_obj.id, instance_obj.id, model_type, model_name) - if model_entity: - if model_entity.status != ActiveStatusEnum.ACTIVE.value: - return False, f"Model '{model_name}' isn't available" - return True, None - - llms = factory_info[0].get("llm", []) - target_llm = [llm for llm in llms if llm["llm_name"] == model_name] - if not target_llm and not model_entity: - return False, f"Model '{model_name}' not found for provider '{provider_name}'" - - if target_llm: - if model_type not in _factory_model_types(target_llm[0]): - return False, f"Model '{model_name}' isn't a {model_type} model" - + model_record = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, model_name) + if model_record.status != ActiveStatusEnum.ACTIVE.value: + return False, f"Model '{model_name}' isn't available" + if model_type not in get_model_type_human(model_record.model_type): + return False, f"Model '{model_name}' isn't a {model_type} model" return True, None @@ -301,8 +268,7 @@ def list_tenant_added_models(tenant_id: str, model_type_filter: str = None): ensure_paddleocr_from_env(tenant_id) ensure_opendataloader_from_env(tenant_id) - if model_type_filter: - model_type_filter = model_type_filter.lower() + model_type_filter_bin = calculate_model_type(model_type_filter.lower()) if model_type_filter else None providers = TenantModelProviderService.get_by_tenant_id(tenant_id) if not providers: @@ -314,6 +280,7 @@ def list_tenant_added_models(tenant_id: str, model_type_filter: str = None): return True, [] provider_instance_map: dict = {} provider_info_map = {provider.id: provider for provider in providers} + instance_info_map = {instance.id: instance for instance in instances} for provider_instance_record in instances: provider_name = provider_info_map[provider_instance_record.provider_id].provider_name if provider_info_map.get(provider_instance_record.provider_id) else "" if provider_instance_map.get(provider_name): @@ -322,80 +289,17 @@ def list_tenant_added_models(tenant_id: str, model_type_filter: str = None): provider_instance_map[provider_name] = [provider_instance_record] model_records = TenantModelService.get_models_by_provider_ids_and_instance_ids(provider_ids, list({instance.id for instance in instances})) - target_type_records = [record for record in model_records if record.model_type == model_type_filter] if model_type_filter else model_records - model_record_map = {} - for model in target_type_records: - instance_model_key = f"{model.provider_id}|{model.instance_id}|{model.model_name}" - if model_record_map.get(instance_model_key): - model_record_map[instance_model_key].append(model) - else: - model_record_map[instance_model_key] = [model] + target_type_records = [record for record in model_records if record.model_type & model_type_filter_bin] if model_type_filter_bin else model_records - added_models = [] - model_key_in_factory = [] - provider_names = [provider.provider_name for provider in providers] factory_rank_mapping = {factory["name"]: -_to_int(factory.get("rank", "500")) for factory in FACTORY_LLM_INFOS} - for factory in FACTORY_LLM_INFOS: - if factory["name"] not in provider_names: - continue - factory_instances = provider_instance_map.get(factory["name"]) - if not factory_instances: - continue - for llm in factory["llm"]: - factory_model_types = _factory_model_types(llm) - if model_type_filter and model_type_filter not in factory_model_types: - continue - - for factory_instance in factory_instances: - model_record_key = f"{factory_instance.provider_id}|{factory_instance.id}|{llm['llm_name']}" - model_key_in_factory.append(model_record_key) - manual_modified_models = model_record_map.get(model_record_key, []) - active_model_types = [manual_model.model_type for manual_model in manual_modified_models if manual_model.status == ActiveStatusEnum.ACTIVE.value] - inactive_model_types = [manual_model.model_type for manual_model in manual_modified_models if manual_model.status == ActiveStatusEnum.INACTIVE.value] - unsupport_model_types = [manual_model.model_type for manual_model in manual_modified_models if manual_model.status == ActiveStatusEnum.UNSUPPORTED.value] - model_types = list(set(factory_model_types + active_model_types) - set(inactive_model_types) - set(unsupport_model_types)) - if not model_types: - continue - - added_models.append( - { - "model_type": model_types, - "name": llm["llm_name"], - "provider_id": factory_instance.provider_id, - "provider_name": provider_info_map[factory_instance.provider_id].provider_name if provider_info_map.get(factory_instance.provider_id) else "", - "instance_id": factory_instance.id, - "instance_name": factory_instance.instance_name, - } - ) - - manual_added_model_record_keys = list(set(model_record_map.keys()) - set(model_key_in_factory)) - if manual_added_model_record_keys: - instance_info_map = {instance.id: instance for instance in instances} - for model_record_key in manual_added_model_record_keys: - model_records = model_record_map.get(model_record_key, []) - if not model_records: - continue - # The internal key uses '|' as separator (UUID|UUID|model_name) - # since model_name may contain '@' characters. - try: - provider_id, instance_id, model_name = model_record_key.split("|", 2) - except ValueError: - logging.warning(f"Skipping malformed manual model record key: {model_record_key!r}") - continue - model_types = [model.model_type for model in model_records if model.status == ActiveStatusEnum.ACTIVE.value] - if not model_types: - continue - - added_models.append( - { - "model_type": model_types, - "name": model_name, - "provider_id": provider_id, - "provider_name": provider_info_map[provider_id].provider_name if provider_info_map.get(provider_id) else "", - "instance_id": instance_id, - "instance_name": instance_info_map[instance_id].instance_name if instance_info_map.get(instance_id) else "", - } - ) + added_models = [{ + "model_type": get_model_type_human(model_record.model_type), + "name": model_record.model_name, + "provider_id": model_record.provider_id, + "provider_name": provider_info_map[model_record.provider_id].provider_name, + "instance_id": model_record.instance_id, + "instance_name": instance_info_map[model_record.instance_id].instance_name + } for model_record in target_type_records] # Add TEI Builtin embedding model if configured compose_profiles = os.getenv("COMPOSE_PROFILES", "") @@ -415,6 +319,7 @@ def list_tenant_added_models(tenant_id: str, model_type_filter: str = None): } ) - added_models.sort(key=lambda x: (factory_rank_mapping.get(x["provider_name"]), x["provider_name"], x["instance_name"])) + added_models.sort( + key=lambda x: (factory_rank_mapping.get(x["provider_name"]), x["provider_name"], x["instance_name"])) return True, added_models diff --git a/api/apps/services/provider_api_service.py b/api/apps/services/provider_api_service.py index 795885d026..e508ca2d0c 100644 --- a/api/apps/services/provider_api_service.py +++ b/api/apps/services/provider_api_service.py @@ -18,13 +18,13 @@ import json import logging import asyncio -from common.constants import LLMType, ActiveStatusEnum -from common.misc_utils import get_uuid +from common.constants import LLMType, ActiveStatusEnum, ModelVerifyStatusEnum from common.settings import FACTORY_LLM_INFOS -from api.db.joint_services.tenant_model_service import get_model_config_from_provider_instance, delete_models_by_instance_ids, delete_instances_by_provider_ids +from api.db.joint_services.tenant_model_service import get_model_config_from_provider_instance, delete_models_by_instance_ids, delete_instances_by_provider_ids, _decode_api_key_config from api.db.services.tenant_model_provider_service import TenantModelProviderService from api.db.services.tenant_model_instance_service import TenantModelInstanceService from api.db.services.tenant_model_service import TenantModelService +from api.utils.model_utils import get_model_type_human, calculate_model_type from rag.llm import ChatModel, EmbeddingModel, ModelMeta, OcrModel, RerankModel, TTSModel @@ -66,7 +66,7 @@ def list_providers(tenant_id: str, all_available: bool = False): List providers for a tenant. If available_only is True, list all system-wide providers (pool providers). - Otherwise, list providers that the tenant has configured. + Otherwise, list providers that the tenant has configured, with a has_instance flag. :param tenant_id: tenant ID :param all_available: whether to list all available providers @@ -102,11 +102,13 @@ def list_providers(tenant_id: str, all_available: bool = False): for name in factory_names: if name not in ["Youdao", "FastEmbed", "BAAI", "Builtin", "siliconflow_intl"] and factory_info_mapping.get(name): factory_info = factory_info_mapping[name] + provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, name) + has_instance = bool(provider_obj and TenantModelInstanceService.get_all_by_provider_id(provider_obj.id)) model_types = sorted(set(model_type for llm in factory_info.get("llm", []) for model_type in _factory_model_types(llm))) if factory_info.get("llm", []) else [] if name in ["MinerU", "PaddleOCR", "OpenDataLoader"]: model_types.append("ocr") - provider = {"model_types": model_types, "name": factory_info["name"], "url": {"default": factory_info.get("url", "")}} + provider = {"has_instance": has_instance, "model_types": model_types, "name": factory_info["name"], "url": {"default": factory_info.get("url", "")}} if factory_info["name"].lower() == "siliconflow": provider["url"]["intl"] = factory_info_map.get("siliconflow_intl", {}).get("url", "https://api.siliconflow.com/v1") elif factory_info["name"] == "Tongyi-Qianwen": @@ -199,7 +201,7 @@ async def list_provider_models(provider_id_or_name: str, api_key: str = None, ba static_llms = [ { "name": _factory_llm_name(llm), - "max_tokens": llm["max_tokens"], + "max_tokens": llm.get("max_tokens", 8192), "model_types": _factory_model_types(llm), "features": (llm.get("features") if llm.get("features") is not None else ((["is_tools"] if llm.get("is_tools") else []) + (["thinking"] if llm.get("thinking") else []))), } @@ -255,6 +257,181 @@ def show_provider_model(provider_id_or_name: str, model_name: str): } +async def update_provider_instance(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, instance_name: str, api_key: str|dict, base_url: str, region: str, model_info: list[dict]=None, verify: bool=True): + """ + Update a provider instance. + + Updates the instance's api_key, base_url, region, and re-creates all models + based on the provided model_info list. + + :param tenant_id: tenant ID + :param provider_id_or_name: provider/factory ID or name + :param instance_id_or_name: instance ID or name + :param instance_name: instance name (used as a logical identifier) + :param api_key: API key + :param base_url: base url + :param region: region + :param model_info: model info, [{ + "model_type": ["chat"], # support multiple + "model_name": "name", + "max_tokens": 4096, + "extra": { + "is_tools": True + } + }] + :param verify: verify api_key + :return: (success, result_or_error_message) + """ + if not provider_id_or_name: + return False, "Provider ID or name is required" + + provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_id(tenant_id, provider_id_or_name) + if not provider_obj: + provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_id_or_name) + if not provider_obj: + return False, f"Provider '{provider_id_or_name}' does not exist" + + provider_name = provider_obj.provider_name + + # Find the instance + instance_obj = None + if instance_id_or_name: + _, instance_obj = TenantModelInstanceService.get_by_id(instance_id_or_name) + if instance_obj and instance_obj.provider_id != provider_obj.id: + instance_obj = None + if not instance_obj: + instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_id_or_name) + if not instance_obj: + return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" + + base_url = _normalize_provider_base_url(provider_name, base_url) + api_key = _normalize_provider_api_key(provider_name, api_key) + + api_key_str = "" + if api_key: + api_key_str = api_key if isinstance(api_key, str) else json.dumps(api_key) + + # Verify api_key + model_verify_result = {} + if verify: + success, msg, model_verify_result = await verify_api_key(provider_name, api_key, base_url, region, model_info) + if not success: + return False, msg + + # Update instance record + update_dict = { + "api_key": api_key_str, + } + if instance_name != instance_obj.instance_name: + update_dict["instance_name"] = instance_name + + extra_fields = {} + if base_url: + extra_fields["base_url"] = base_url + if region: + extra_fields["region"] = region + # Preserve existing extra fields not overwritten + existing_extra = json.loads(instance_obj.extra) if instance_obj.extra else {} + existing_extra.update(extra_fields) + update_dict["extra"] = json.dumps(existing_extra) + TenantModelInstanceService.update_by_id(instance_obj.id, update_dict) + + # Use the (possibly updated) instance_name for model recreation + effective_instance_name = instance_name + + # Upsert models: add new ones, update existing ones, remove ones no longer selected + existing_model_objs = TenantModelService.get_models_by_instance_id(instance_obj.id) + existing_model_names = {model_obj.model_name: model_obj for model_obj in existing_model_objs} + + # Delete models that are no longer in the submitted model_info + submitted_model_names = set() + if model_info: + submitted_model_names = {m.get("model_name") for m in model_info if m.get("model_name")} + elif model_info is not None: + # model_info is explicitly an empty list — remove all models + submitted_model_names = set() + models_to_remove = set(existing_model_names.keys()) - submitted_model_names + if models_to_remove: + TenantModelService.delete_by_ids([existing_model_names[n].id for n in models_to_remove]) + + msg = "" + if model_info: + for model in model_info: + model_name = model.get("model_name") + if not model_name: + continue + if verify: + verify_status = model_verify_result.get(model_name, ModelVerifyStatusEnum.UNKNOWN.value) + if model.get("extra"): + model["extra"].update({"verify": verify_status}) + else: + model["extra"] = {"verify": verify_status} + + if model_name in existing_model_names: + # Update existing model + update_dict = {} + if isinstance(model.get("model_type"), (str, list)): + target_model_type = calculate_model_type(model["model_type"]) + if target_model_type != existing_model_names[model_name].model_type: + update_dict["model_type"] = target_model_type + merged_extra = json.loads(existing_model_names[model_name].extra) if existing_model_names[model_name].extra else {} + merged_extra.update(model["extra"]) + if "max_tokens" in model: + merged_extra.update({"max_tokens": model["max_tokens"]}) + update_dict["extra"] = json.dumps(merged_extra) + if update_dict: + TenantModelService.update_model(existing_model_names[model_name].id, update_dict) + else: + # Add new model + success, _msg = add_model_to_instance(tenant_id, provider_name, effective_instance_name, **model) + if not success: + msg += _msg + else: + if model_info is None: + # model_info not provided — add all factory default models (same as create) + factory_info = [f for f in FACTORY_LLM_INFOS if f["name"] == provider_name] + factory_llms = factory_info[0]["llm"] + for llm in factory_llms: + llm_name = _factory_llm_name(llm) + if llm_name in existing_model_names: + # Update existing + update_dict = {} + target_model_type = calculate_model_type(_factory_model_types(llm)) + if target_model_type != existing_model_names[llm_name].model_type: + update_dict["model_type"] = target_model_type + db_extra = json.loads(existing_model_names[llm_name].extra) if existing_model_names[llm_name].extra else {} + db_extra_fields = { + "max_tokens": llm["max_tokens"], + "is_tools": llm.get("is_tools", False), + "thinking": "thinking" in llm.get("features", []), + } + if verify: + verify_status = model_verify_result.get(llm_name, ModelVerifyStatusEnum.UNKNOWN.value) + db_extra_fields["verify"] = verify_status + db_extra.update(db_extra_fields) + update_dict["extra"] = json.dumps(db_extra) + if update_dict: + TenantModelService.update_model(existing_model_names[llm_name].id, update_dict) + else: + extra_fields = { + "is_tools": llm.get("is_tools", False), + "thinking": "thinking" in llm.get("features", []), + } + if verify: + verify_status = model_verify_result.get(llm_name, ModelVerifyStatusEnum.UNKNOWN.value) + extra_fields["verify"] = verify_status + success, _msg = add_model_to_instance(tenant_id, provider_name, effective_instance_name, **{ + "model_type": _factory_model_types(llm), + "model_name": llm_name, + "max_tokens": llm["max_tokens"], + "extra": extra_fields + }) + if not success: + msg += _msg + + return True, "success" + + async def create_provider_instance(tenant_id: str, provider_id_or_name: str, instance_name: str, api_key: str | dict, base_url: str, region: str, model_info: list[dict] = None): """ Create a provider instance. @@ -305,17 +482,9 @@ async def create_provider_instance(tenant_id: str, provider_id_or_name: str, ins if api_key: api_key_str = api_key if isinstance(api_key, str) else json.dumps(api_key) - # Only verify when there are models to probe. Generic providers such as - # "OpenAI-API-Compatible" may start empty and receive custom models later. - factory_entry = next((f for f in FACTORY_LLM_INFOS if f["name"] == provider_name), None) - if (factory_entry and factory_entry.get("llm")) or model_info: - success, msg = await verify_api_key(provider_name, api_key, base_url, region, model_info) - if not success: - return False, msg - - success, msg = await verify_api_key(provider_name, api_key, base_url, region, model_info) + success, verify_msg, model_verify_result = await verify_api_key(provider_name, api_key, base_url, region, model_info) if not success: - return False, msg + return False, verify_msg extra_fields = {} if base_url: @@ -326,15 +495,72 @@ async def create_provider_instance(tenant_id: str, provider_id_or_name: str, ins if model_info: msg = "" for model in model_info: + if model.get("extra"): + model["extra"].update({"verify": model_verify_result.get(model["model_name"], ModelVerifyStatusEnum.UNKNOWN.value)}) + else: + model["extra"] = {"verify": model_verify_result.get(model["model_name"], ModelVerifyStatusEnum.UNKNOWN.value)} success, _msg = add_model_to_instance(tenant_id, provider_name, instance_name, **model) if not success: msg += _msg if msg: return False, msg + else: + msg = "" + target_factory_name = "siliconflow_intl" if provider_name.lower() == "siliconflow" and region == "intl" else provider_name + factory_info = [f for f in FACTORY_LLM_INFOS if f["name"] == target_factory_name] + factory_llms = factory_info[0]["llm"] + for llm in factory_llms: + llm_name = _factory_llm_name(llm) + success, _msg = add_model_to_instance(tenant_id, provider_name, instance_name, **{ + "model_type": _factory_model_types(llm), + "model_name": llm_name, + "max_tokens": llm["max_tokens"], + "extra": { + "is_tools": llm.get("is_tools", False), + "thinking": "thinking" in llm.get("features", []), + "verify": model_verify_result.get(llm_name, ModelVerifyStatusEnum.UNKNOWN.value) + } + }) + if not success: + msg += _msg + if msg: + return False, msg return True, "success" +async def create_name_only_provider_instance(tenant_id: str, provider_name: str, instance_name: str): + """ + Create a provider instance with only a name (no api_key/base_url validation). + + :param tenant_id: tenant ID + :param provider_name: provider/factory name + :param instance_name: instance name (used as a logical identifier) + :return: (success, result_or_error_message) + """ + if not provider_name: + return False, "Provider name is required" + + if instance_name == "default": + return False, "Instance name cannot be 'default'" + + allowed_factories = [f["name"] for f in FACTORY_LLM_INFOS] + if provider_name not in allowed_factories: + return False, f"Provider '{provider_name}' is not allowed" + + provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) + if not provider_obj: + return False, f"Provider '{provider_name}' does not exist" + + TenantModelInstanceService.create_instance( + provider_id=provider_obj.id, + instance_name=instance_name, + api_key="", + extra=json.dumps({}) + ) + return True, "success" + + def list_provider_instances(tenant_id: str, provider_id_or_name: str): """ List provider instances for a tenant. @@ -388,7 +614,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url :return: (success, result_or_error_message) """ if not provider_id_or_name: - return False, "Provider ID or name is required" + return False, "Provider ID or name is required", {} provider_obj = None if provider_id_or_name: @@ -405,24 +631,21 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url factory_info = [f for f in FACTORY_LLM_INFOS if f["name"] == target_factory_name] if not factory_info: - return False, f"Provider '{provider_id_or_name}' not found" + return False, f"Provider '{provider_id_or_name}' not found", {} - factory_llms = factory_info[0]["llm"] - if not factory_llms: - if not model_info: - return False, f"No models found for provider '{provider_id_or_name}'" - factory_llms = [ - { - "model_type": _type, - "llm_name": model.get("model_name", ""), - } - for model in model_info - if model - for _type in model.get("model_type", []) - ] + if model_info: + factory_llms = [{ + "model_type": _type, + "llm_name": model.get("model_name", ""), + } for model in model_info if model for _type in model.get("model_type", [])] if not factory_llms: - return False, f"No valid models found for provider '{provider_id_or_name}'" + return False, f"No valid models found for provider '{provider_id_or_name}'", {} + else: + factory_llms = factory_info[0]["llm"] + if not factory_llms: + return False, f"No models found for provider '{provider_id_or_name}'", {} + model_verify_result = {} # test if api key works chat_passed, embd_passed, rerank_passed, ocr_passed, tts_passed = False, False, False, False, False timeout_seconds = int(os.environ.get("LLM_TIMEOUT_SECONDS", 10)) @@ -448,6 +671,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url if len(arr[0]) == 0: raise Exception("Fail") embd_passed = True + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.SUCCESS.value except Exception as e: logging.exception( "Fail to access embedding model for provider=%s model=%s", @@ -455,6 +679,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url llm["llm_name"], ) msg += f"\nFail to access embedding model({llm['llm_name']}) using this api key." + str(e) + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.FAIL.value elif not chat_passed and LLMType.CHAT.value in model_types: assert provider_name in ChatModel, f"Chat model from {provider_name} is not supported yet." mdl = ChatModel[provider_name](api_key_str, llm["llm_name"], base_url=base_url, **extra) @@ -472,8 +697,10 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url result = await asyncio.wait_for(check_streamly(), timeout=timeout_seconds) if result: + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.SUCCESS.value chat_passed = True else: + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.FAIL.value raise Exception("No valid response received") except Exception as e: logging.exception( @@ -481,6 +708,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url provider_name, llm["llm_name"], ) + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.FAIL.value msg += f"\nFail to access model({provider_name}/{llm['llm_name']}) using this api key." + str(e) elif not rerank_passed and LLMType.RERANK.value in model_types: if provider_name not in RerankModel: @@ -497,6 +725,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url if len(arr) == 0 or tc == 0: raise Exception("Fail") rerank_passed = True + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.SUCCESS.value logging.debug(f"passed model rerank {llm['llm_name']}") except Exception as e: logging.exception( @@ -504,6 +733,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url provider_name, llm["llm_name"], ) + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.FAIL.value msg += f"\nFail to access model({provider_name}/{llm['llm_name']}) using this api key." + str(e) elif not ocr_passed and LLMType.OCR.value in model_types: assert provider_name in OcrModel, f"OCR model from {provider_name} is not supported yet." @@ -515,6 +745,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url ) if not ok: raise RuntimeError(reason or "Model not available") + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.SUCCESS.value ocr_passed = True except Exception as e: logging.exception( @@ -522,6 +753,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url provider_name, llm["llm_name"], ) + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.FAIL.value msg += f"\nFail to access model({provider_name}/{llm['llm_name']})." + str(e) elif not tts_passed and LLMType.TTS.value in model_types: assert provider_name in TTSModel, f"TTS model from {provider_name} is not supported yet." @@ -536,6 +768,7 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url asyncio.to_thread(drain_tts), timeout=timeout_seconds, ) + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.SUCCESS.value tts_passed = True except Exception as e: logging.exception( @@ -543,13 +776,14 @@ async def verify_api_key(provider_id_or_name: str, api_key: str | dict, base_url provider_name, llm["llm_name"], ) + model_verify_result[llm["llm_name"]] = ModelVerifyStatusEnum.FAIL.value msg += f"\nFail to access model({provider_name}/{llm['llm_name']})." + str(e) if any([embd_passed, chat_passed, rerank_passed, ocr_passed, tts_passed]): msg = "" break success = any([embd_passed, chat_passed, rerank_passed, ocr_passed, tts_passed]) - return success, "success" if success else msg + return success, "success" if success else msg, model_verify_result def show_provider_instance(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str): @@ -578,7 +812,16 @@ def show_provider_instance(tenant_id: str, provider_id_or_name: str, instance_id return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {} - return True, {"id": instance_obj.id, "instance_name": instance_obj.instance_name, "provider_id": provider_id, "region": extra_fields.get("region", ""), "status": instance_obj.status} + + return True, { + "id": instance_obj.id, + "instance_name": instance_obj.instance_name, + "provider_id": provider_id, + "region": extra_fields.get("region", ""), + "base_url": extra_fields.get("base_url", ""), + "api_key": instance_obj.api_key, + "status": instance_obj.status + } def drop_provider_instances(tenant_id: str, provider_id_or_name: str, instance_id_or_names: list): @@ -618,55 +861,6 @@ def drop_provider_instances(tenant_id: str, provider_id_or_name: str, instance_i return True, None -def _hybrid_get_instance_models(provider_name: str, instance_id: str): - # List all models from the LLM dictionary for this provider - factory_info = [f for f in FACTORY_LLM_INFOS if f["name"] == provider_name] - if not factory_info: - return False, f"Provider '{provider_name}' not found" - - # Get model records for this instance from tenant_model table - model_records = TenantModelService.get_models_by_instance_id(instance_id) - # Build a map of model_name -> status, type - model_info_map: dict = {} - model_unsupported_type_map = {} - for model_record in model_records: - if model_record.status == ActiveStatusEnum.UNSUPPORTED.value: - if model_unsupported_type_map.get(model_record.model_name): - model_unsupported_type_map[model_record.model_name].append(model_record.model_type) - else: - model_unsupported_type_map[model_record.model_name] = [model_record.model_type] - continue - if model_info_map.get(model_record.model_name): - model_info_map[model_record.model_name]["model_type"].append(model_record.model_type) - else: - model_info_map[model_record.model_name] = {"status": model_record.status, "model_type": [model_record.model_type], "extra": model_record.extra} - - llms = factory_info[0].get("llm", []) - models = [] - for llm in llms: - models.append( - { - "name": llm["llm_name"], - "model_type": list(set(_factory_model_types(llm) + model_info_map.get(llm["llm_name"], {}).get("model_type", [])) - set(model_unsupported_type_map.get(llm["llm_name"], []))), - "max_tokens": llm.get("max_tokens"), - "status": model_info_map.get(llm["llm_name"], {}).get("status", "active"), - } - ) - factory_models = [m["name"] for m in models] - for model_name, model_info_dict in model_info_map.items(): - if model_name not in factory_models: - extra_fields = json.loads(model_info_dict["extra"]) if model_info_dict["extra"] else {} - models.append( - { - "name": model_name, - "model_type": set(model_info_dict["model_type"]) - set(model_unsupported_type_map.get(model_name, [])), - "max_tokens": extra_fields.get("max_tokens", 8192), - "status": model_info_dict["status"], - } - ) - return True, models - - def list_instance_models(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, supported_only: bool = False): """ List models for a provider instance. @@ -708,8 +902,21 @@ def list_instance_models(tenant_id: str, provider_id_or_name: str, instance_id_o instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_id_or_name) if not instance_obj: return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" + # Get models + model_objs = TenantModelService.get_models_by_instance_id(instance_obj.id) + model_list = [] + for model in model_objs: + model_extra = json.loads(model.extra) + model_list.append({ + "name": model.model_name, + "model_type": get_model_type_human(model.model_type), + "max_tokens": model_extra.get("max_tokens", 8192) if model.extra else 8192, + "status": model.status, + "verify": model_extra.get("verify", ModelVerifyStatusEnum.UNKNOWN.value), + "features": (["is_tools"] if model_extra.get("is_tools") else []) + (["thinking"] if model_extra.get("thinking") else []) + }) - return _hybrid_get_instance_models(provider_obj.provider_name, instance_obj.id) + return True, model_list def update_instance_models(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, model_names: list, model_types: list): @@ -731,19 +938,16 @@ def update_instance_models(tenant_id: str, provider_id_or_name: str, instance_id if not instance_obj: return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" - found, models = _hybrid_get_instance_models(provider_obj.provider_name, instance_obj.id) - if not found: - return False, models - - model_info_map = {model["name"]: model for model in models} - not_exist_models = set(model_names) - set(model_info_map.keys()) + model_objs = TenantModelService.get_models_by_instance_id(instance_obj.id) + not_exist_models = set(model_names) - {model_obj.model_name for model_obj in model_objs} if not_exist_models: return False, f"Models {not_exist_models} not found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" - for model_name in model_names: - model_info = model_info_map.get(model_name, {}) - TenantModelService.upsert_model_type( - provider_obj.id, instance_obj.id, model_name, {"add": list(set(model_types) - set(model_info["model_type"])), "delete": list(set(model_info["model_type"]) - set(model_types))} - ) + + target_model_type_bin = calculate_model_type(model_types) + to_update = [model_obj.id for model_obj in model_objs if model_obj.model_type != target_model_type_bin and model_obj.model_name in model_names] + if to_update: + TenantModelService.batch_update_model_type(to_update, target_model_type_bin) + return True, "success" @@ -772,22 +976,26 @@ def add_model_to_instance(tenant_id: str, provider_id_or_name: str, instance_id_ if isinstance(model_type, str): model_type = [model_type] - for _type in model_type: - extra_fields = {"max_tokens": max_tokens} - target_model = [llm for llm in llms if _type in _factory_model_types(llm) and llm["llm_name"] == model_name] - if target_model: - extra_fields.update({"is_tools": target_model[0].get("is_tools", False)}) - if extra: - if provider_id_or_name == "SoMark" and LLMType.OCR.value in model_type: - extra_fields["ocr_config"] = extra - else: - extra_fields.update(extra) - TenantModelService.insert(model_name=model_name, provider_id=provider_obj.id, instance_id=instance_obj.id, model_type=_type, extra=json.dumps(extra_fields)) + model_type_bin = calculate_model_type(model_type) + extra_fields = {"max_tokens": max_tokens} + target_model = [llm for llm in llms if llm["llm_name"] == model_name] + if target_model: + extra_fields.update({"is_tools": target_model[0].get("is_tools", False)}) + extra_fields.update({"thinking": "thinking" in target_model[0].get("features", [])}) + if extra: + extra_fields.update(extra) + TenantModelService.insert( + model_name=model_name, + provider_id=provider_obj.id, + instance_id=instance_obj.id, + model_type=model_type_bin, + extra=json.dumps(extra_fields) + ) return True, "success" -def update_model_status(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, model_name: str, status: str): +def update_model(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, model_name: str, update_dict: dict): """ Enable or disable a model for a provider instance. @@ -800,10 +1008,12 @@ def update_model_status(tenant_id: str, provider_id_or_name: str, instance_id_or :param provider_id_or_name: provider/factory ID or name :param instance_id_or_name: instance ID or name :param model_name: model name - :param status: "active" or "inactive" (ActiveStatusEnum values) + :param update_dict: + status: "active" or "inactive" (ActiveStatusEnum values) + max_tokens: > 0 :return: (success, result_or_error_message) """ - if status not in (ActiveStatusEnum.ACTIVE.value, ActiveStatusEnum.INACTIVE.value): + if update_dict.get("status") and update_dict["status"] not in (ActiveStatusEnum.ACTIVE.value, ActiveStatusEnum.INACTIVE.value): return False, f"status must be '{ActiveStatusEnum.ACTIVE.value}' or '{ActiveStatusEnum.INACTIVE.value}'" # Check if provider exists for this tenant @@ -824,39 +1034,65 @@ def update_model_status(tenant_id: str, provider_id_or_name: str, instance_id_or if not instance_obj: return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" - # Check if model record already exists in tenant_model table - model_obj_list = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, model_name) + model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, model_name) + to_update = {} + if "status" in update_dict and update_dict.get("status") != model_obj.status: + to_update.update({"status": update_dict["status"]}) + new_extra = update_dict.get("extra", {}) + if "max_tokens" in update_dict: + new_extra.update({"max_tokens": update_dict["max_tokens"]}) + if "verify" in update_dict: + new_extra.update({"verify": update_dict["verify"]}) + if new_extra: + db_extra = json.loads(model_obj.extra) + db_extra.update(**new_extra) + to_update.update({"extra": json.dumps(db_extra)}) + if "model_type" in update_dict: + target_model_type = calculate_model_type(update_dict["model_type"]) + if target_model_type != model_obj.model_type: + to_update.update({"model_type": target_model_type}) - if model_obj_list: - # Model record exists — update its status - TenantModelService.batch_update_model_status([m.id for m in model_obj_list if m.status != ActiveStatusEnum.UNSUPPORTED.value], status) - else: - # Model record does not exist - if status == ActiveStatusEnum.ACTIVE.value: - # Default is active, no need to add a record - return True, None - # status is "inactive" — create a record with inactive status - # Look up model schema from FACTORY_LLM_INFOS - factory_info = [f for f in FACTORY_LLM_INFOS if f["name"] == provider_obj.provider_name] - if not factory_info: - return False, f"Provider '{provider_id_or_name}' not found" - llms = factory_info[0].get("llm", []) - target_llm = [llm for llm in llms if llm["llm_name"] == model_name] - if not target_llm: - return False, f"provider {provider_obj.provider_name} model {model_name} not found" + if to_update: + TenantModelService.update_model(model_obj.id, to_update) - for model_type in _factory_model_types(target_llm[0]): - TenantModelService.insert( - id=get_uuid(), - model_name=model_name, - model_type=model_type, - provider_id=provider_obj.id, - instance_id=instance_obj.id, - status=status, - extra=json.dumps({"max_tokens": target_llm[0].get("max_tokens", 8192), "is_tools": target_llm[0].get("is_tools", False)}), - ) + return True, "success" - return True, None + +async def delete_models_from_instance(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, model_name: list[str]): + """ + Delete models from instance. + + :param tenant_id: tenant ID + :param provider_id_or_name: provider/factory ID or name + :param instance_id_or_name: instance ID or name + :param model_name: list of model name + """ + # Check if provider exists for this tenant (by ID first, then by name) + provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_id(tenant_id, provider_id_or_name) + if not provider_obj: + provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_id_or_name) + if not provider_obj: + return False, f"No provider found for provider '{provider_id_or_name}'" + + # Check if instance exists (by ID first, then by name) + instance_obj = None + if instance_id_or_name: + _, instance_obj = TenantModelInstanceService.get_by_id(instance_id_or_name) + if instance_obj and instance_obj.provider_id != provider_obj.id: + instance_obj = None + if not instance_obj: + instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_id_or_name) + if not instance_obj: + return False, f"No instance found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" + + model_objs = TenantModelService.get_models_by_instance_id(instance_obj.id) + not_exist_models = set(model_name) - {model_obj.model_name for model_obj in model_objs} + if not_exist_models: + return False, f"Models {not_exist_models} not found for provider '{provider_id_or_name}' and instance '{instance_id_or_name}'" + + TenantModelService.delete_by_ids([model_obj.id for model_obj in model_objs if model_obj.model_name in model_name]) + + return True, "success" async def chat_to_model(tenant_id: str, provider_id_or_name: str, instance_id_or_name: str, model_name: str, message: str, stream: bool = False, thinking: bool = False): @@ -896,7 +1132,7 @@ async def chat_to_model(tenant_id: str, provider_id_or_name: str, instance_id_or # Get model config composite_name = f"{model_name}@{instance_name}@{provider_name}" try: - model_config = get_model_config_from_provider_instance(tenant_id, LLMType.CHAT.value, composite_name) + model_config = get_model_config_from_provider_instance(tenant_id, LLMType.CHAT, composite_name) except LookupError: return False, f"Model '{composite_name}' not authorized" diff --git a/api/db/db_models.py b/api/db/db_models.py index 74dc5e5c0b..ca4ba8a5a1 100644 --- a/api/db/db_models.py +++ b/api/db/db_models.py @@ -725,18 +725,19 @@ class Tenant(DataBaseModel): name = CharField(max_length=100, null=True, help_text="Tenant name", index=True) public_key = CharField(max_length=255, null=True, index=True) llm_id = CharField(max_length=128, null=False, help_text="default llm ID", index=True) - tenant_llm_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_llm_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) embd_id = CharField(max_length=128, null=False, help_text="default embedding model ID", index=True) - tenant_embd_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_embd_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) asr_id = CharField(max_length=128, null=False, help_text="default ASR model ID", index=True) - tenant_asr_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_asr_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) img2txt_id = CharField(max_length=128, null=False, help_text="default image to text model ID", index=True) - tenant_img2txt_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_img2txt_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) rerank_id = CharField(max_length=128, null=False, help_text="default rerank model ID", index=True) - tenant_rerank_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_rerank_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) tts_id = CharField(max_length=256, null=True, help_text="default tts model ID", index=True) - tenant_tts_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_tts_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) ocr_id = CharField(max_length=256, null=True, help_text="default OCR model ID", index=True) + tenant_ocr_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) parser_ids = CharField(max_length=256, null=False, help_text="document processors", index=True) credit = IntegerField(default=512, index=True) status = CharField(max_length=1, null=True, help_text="is it validate(0: wasted, 1: validate)", default="1", index=True) @@ -803,7 +804,6 @@ class LLM(DataBaseModel): class TenantLLM(DataBaseModel): - id = PrimaryKeyField() tenant_id = CharField(max_length=32, null=False, index=True) llm_factory = CharField(max_length=128, null=False, help_text="LLM factory name", index=True) model_type = CharField(max_length=128, null=True, help_text="LLM, Text Embedding, Image2Text, ASR", index=True) @@ -843,7 +843,7 @@ class Knowledgebase(DataBaseModel): language = CharField(max_length=32, null=True, default="Chinese" if "zh_CN" in os.getenv("LANG", "") else "English", help_text="English|Chinese", index=True) description = TextField(null=True, help_text="KB description") embd_id = CharField(max_length=128, null=False, help_text="default embedding model ID", index=True) - tenant_embd_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_embd_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) permission = CharField(max_length=16, null=False, help_text="me|team", default="me", index=True) created_by = CharField(max_length=32, null=False, index=True) doc_num = IntegerField(default=0, index=True) @@ -1011,7 +1011,7 @@ class Dialog(DataBaseModel): icon = TextField(null=True, help_text="icon base64 string") language = CharField(max_length=32, null=True, default="Chinese" if "zh_CN" in os.getenv("LANG", "") else "English", help_text="English|Chinese", index=True) llm_id = CharField(max_length=128, null=False, help_text="default llm ID") - tenant_llm_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_llm_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) llm_setting = JSONField(null=False, default={"temperature": 0.1, "top_p": 0.3, "frequency_penalty": 0.7, "presence_penalty": 0.4, "max_tokens": 512}) prompt_type = CharField(max_length=16, null=False, default="simple", help_text="simple|advanced", index=True) @@ -1031,7 +1031,7 @@ class Dialog(DataBaseModel): do_refer = CharField(max_length=1, null=False, default="1", help_text="it needs to insert reference index into answer or not") rerank_id = CharField(max_length=128, null=False, help_text="default rerank model ID") - tenant_rerank_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_rerank_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) kb_ids = JSONField(null=False, default=[]) status = CharField(max_length=1, null=True, help_text="is it validate(0: wasted, 1: validate)", default="1", index=True) @@ -1408,9 +1408,9 @@ class Memory(DataBaseModel): memory_type = IntegerField(null=False, default=1, index=True, help_text="Bit flags (LSB->MSB): 1=raw, 2=semantic, 4=episodic, 8=procedural. E.g., 5 enables raw + episodic.") storage_type = CharField(max_length=32, default="table", null=False, index=True, help_text="table|graph") embd_id = CharField(max_length=128, null=False, index=False, help_text="embedding model ID") - tenant_embd_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_embd_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) llm_id = CharField(max_length=128, null=False, index=False, help_text="chat model ID") - tenant_llm_id = IntegerField(null=True, help_text="id in tenant_llm", index=True) + tenant_llm_id = CharField(max_length=32, null=True, help_text="id in tenant_model", index=True) permissions = CharField(max_length=16, null=False, index=True, help_text="me|team", default="me") description = TextField(null=True, help_text="description") memory_size = IntegerField(default=5242880, null=False, index=False) @@ -1460,7 +1460,7 @@ class TenantModel(DataBaseModel): model_name = CharField(max_length=128, null=True, index=False, help_text="Model name") provider_id = CharField(max_length=32, null=False, index=False) instance_id = CharField(max_length=32, null=False, index=True) - model_type = CharField(max_length=32, null=False, index=False, help_text="Model type") + model_type = IntegerField(null=False, default=1, index=True, help_text="Bit flags (LSB->MSB): 1=chat, 2=embedding, 4=speech2text, 8=image2text, 16=rerank, 32=tts, 64=ocr") status = CharField(max_length=32, default="active", index=False) extra = CharField(max_length=1024, default="{}", index=False) @@ -1790,18 +1790,6 @@ def migrate_db(): # Migrate system_settings.value from CharField to TextField for longer sandbox configs alter_db_column_type(migrator, "system_settings", "value", TextField(null=False, help_text="Configuration value (JSON, string, etc.)")) alter_db_add_column(migrator, "document", "content_hash", CharField(max_length=32, null=True, help_text="xxhash128 of document content for change detection", default="", index=True)) - update_tenant_llm_to_id_primary_key() - alter_db_add_column(migrator, "tenant", "tenant_llm_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "tenant", "tenant_embd_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "tenant", "tenant_asr_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "tenant", "tenant_img2txt_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "tenant", "tenant_rerank_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "tenant", "tenant_tts_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "knowledgebase", "tenant_embd_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "dialog", "tenant_llm_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "dialog", "tenant_rerank_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "memory", "tenant_embd_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) - alter_db_add_column(migrator, "memory", "tenant_llm_id", IntegerField(null=True, help_text="id in tenant_llm", index=True)) alter_db_add_column(migrator, "user_canvas_version", "release", BooleanField(null=False, help_text="is released", default=False, index=True)) alter_db_add_column(migrator, "user_canvas", "tags", CharField(max_length=512, null=False, default="", help_text="Comma-separated tags for organizing agents", index=True)) alter_db_add_column(migrator, "api_4_conversation", "version_title", CharField(max_length=255, null=True, help_text="canvas version title when session created", index=False)) diff --git a/api/db/joint_services/memory_message_service.py b/api/db/joint_services/memory_message_service.py index 2e773bb06c..d6014c8ce3 100644 --- a/api/db/joint_services/memory_message_service.py +++ b/api/db/joint_services/memory_message_service.py @@ -27,7 +27,7 @@ from api.db.db_models import Task from api.db.services.task_service import TaskService from api.db.services.memory_service import MemoryService from api.db.services.llm_service import LLMBundle -from api.db.joint_services.tenant_model_service import get_model_config_from_provider_instance +from api.db.joint_services.tenant_model_service import get_model_config_from_provider_instance, get_model_config_by_id from api.utils.memory_utils import get_memory_type_human from memory.services.messages import MessageService from memory.services.query import MsgTextQuery, get_vector @@ -155,18 +155,8 @@ async def save_extracted_to_memory_only(memory_id: str, message_dict, source_mes return await embed_and_save(memory, message_list, task_id) -async def extract_by_llm( - tenant_id: str, - tenant_llm_id: int, - extract_conf: dict, - memory_type: List[str], - user_input: str, - agent_response: str, - system_prompt: str = "", - user_prompt: str = "", - task_id: str = None, - llm_id: str = "", -) -> List[dict]: +async def extract_by_llm(tenant_id: str, tenant_llm_id: str | None, extract_conf: dict, memory_type: List[str], user_input: str, + agent_response: str, system_prompt: str = "", user_prompt: str="", task_id: str=None, llm_id: str = "") -> List[dict]: if not system_prompt: system_prompt = PromptAssembler.assemble_system_prompt({"memory_type": memory_type}) conversation_content = f"User Input: {user_input}\nAgent Response: {agent_response}" @@ -177,7 +167,13 @@ async def extract_by_llm( user_prompts.append({"role": "user", "content": f"Conversation: {conversation_content}\nConversation Time: {conversation_time}\nCurrent Time: {conversation_time}"}) else: user_prompts.append({"role": "user", "content": PromptAssembler.assemble_user_prompt(conversation_content, conversation_time, conversation_time)}) - llm_config = get_model_config_from_provider_instance(tenant_id, LLMType.CHAT, llm_id) + if tenant_llm_id: + try: + llm_config = get_model_config_by_id(tenant_id, tenant_llm_id) + except LookupError: + llm_config = get_model_config_from_provider_instance(tenant_id, LLMType.CHAT, llm_id) + else: + llm_config = get_model_config_from_provider_instance(tenant_id, LLMType.CHAT, llm_id) with LLMBundle(tenant_id, llm_config) as llm: if task_id: TaskService.update_progress(task_id, {"progress": 0.15, "progress_msg": timestamp_to_date(current_timestamp()) + " " + "Prepared prompts and LLM."}) @@ -197,8 +193,14 @@ async def extract_by_llm( ] -async def embed_and_save(memory, message_list: list[dict], task_id: str = None): - embd_model_config = get_model_config_from_provider_instance(memory.tenant_id, LLMType.EMBEDDING, memory.embd_id) +async def embed_and_save(memory, message_list: list[dict], task_id: str=None): + if memory.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id(memory.tenant_id, memory.tenant_embd_id) + except LookupError: + embd_model_config = get_model_config_from_provider_instance(memory.tenant_id, LLMType.EMBEDDING, memory.embd_id) + else: + embd_model_config = get_model_config_from_provider_instance(memory.tenant_id, LLMType.EMBEDDING, memory.embd_id) with LLMBundle(memory.tenant_id, embd_model_config) as embedding_model: if task_id: TaskService.update_progress(task_id, {"progress": 0.65, "progress_msg": timestamp_to_date(current_timestamp()) + " " + "Prepared embedding model."}) diff --git a/api/db/joint_services/tenant_model_service.py b/api/db/joint_services/tenant_model_service.py index fda95630a9..8304dc4308 100644 --- a/api/db/joint_services/tenant_model_service.py +++ b/api/db/joint_services/tenant_model_service.py @@ -21,6 +21,7 @@ from common import settings from common.constants import ( ActiveStatusEnum, LLMType, + ModelTypeBinary, MINERU_DEFAULT_CONFIG, MINERU_ENV_KEYS, OPENDATALOADER_DEFAULT_CONFIG, @@ -34,6 +35,7 @@ from api.db.services.tenant_llm_service import TenantService from api.db.services.tenant_model_provider_service import TenantModelProviderService from api.db.services.tenant_model_instance_service import TenantModelInstanceService from api.db.services.tenant_model_service import TenantModelService +from api.utils.model_utils import calculate_model_type, get_model_type_human logger = logging.getLogger(__name__) @@ -78,7 +80,7 @@ def _decode_api_key_config(raw_api_key: str) -> tuple[str, bool | None, str | No def get_first_provider_model_name(tenant_id: str, provider_name: str, model_type: str | enum.Enum) -> str | None: - model_type_val = model_type if isinstance(model_type, str) else model_type.value + model_type_bin = calculate_model_type(model_type) provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) if not provider_obj: return None @@ -87,7 +89,7 @@ def get_first_provider_model_name(tenant_id: str, provider_name: str, model_type if instance_obj.status != ActiveStatusEnum.ACTIVE.value: continue for model_obj in TenantModelService.get_models_by_instance_id(instance_obj.id): - if model_obj.model_type == model_type_val and model_obj.status == ActiveStatusEnum.ACTIVE.value: + if model_obj.model_type & model_type_bin and model_obj.status == ActiveStatusEnum.ACTIVE.value: return f"{model_obj.model_name}@{instance_obj.instance_name}@{provider_name}" return None @@ -133,7 +135,7 @@ def _ensure_ocr_provider_from_env(tenant_id: str, provider_name: str, model_name model_name=model_name, provider_id=provider_obj.id, instance_id=instance_obj.id, - model_type=LLMType.OCR.value, + model_type=ModelTypeBinary.OCR.value, extra=json.dumps({"max_tokens": 0}), ) @@ -163,19 +165,26 @@ def get_tenant_default_model_by_type(tenant_id: str, model_type: str | enum.Enum if not exist: raise LookupError("Tenant not found") model_type_val = model_type if isinstance(model_type, str) else model_type.value + model_id: str | None = None model_name: str = "" match model_type_val: case LLMType.EMBEDDING.value: + model_id = tenant.tenant_embd_id model_name = tenant.embd_id case LLMType.SPEECH2TEXT.value: + model_id = tenant.tenant_asr_id model_name = tenant.asr_id case LLMType.IMAGE2TEXT.value: + model_id = tenant.tenant_img2txt_id model_name = tenant.img2txt_id case LLMType.CHAT.value: + model_id = tenant.tenant_llm_id model_name = tenant.llm_id case LLMType.RERANK.value: + model_id = tenant.tenant_rerank_id model_name = tenant.rerank_id case LLMType.TTS.value: + model_id = tenant.tenant_tts_id model_name = tenant.tts_id case LLMType.OCR.value: raise Exception("OCR model name is required") @@ -183,6 +192,12 @@ def get_tenant_default_model_by_type(tenant_id: str, model_type: str | enum.Enum raise Exception(f"Unknown model type {model_type}") if not model_name: raise Exception(f"No default {model_type} model is set.") + # Prefer resolving by tenant_model.id when available + if model_id: + try: + return get_model_config_by_id(tenant_id, model_id) + except LookupError: + logger.warning("tenant_model id=%s not found, falling back to model_name lookup for %s", model_id, model_name) return get_model_config_from_provider_instance(tenant_id, model_type, model_name) @@ -280,7 +295,7 @@ def get_model_config_from_provider_instance(tenant_id, model_type: str | enum.En "api_key": api_key, "llm_name": model_obj.model_name, "api_base": extra_fields.get("base_url", ""), - "model_type": model_obj.model_type, + "model_type": model_type_val, "is_tools": model_extra.get("is_tools", is_tool), "max_tokens": max_tokens, } @@ -294,32 +309,116 @@ def get_model_config_from_provider_instance(tenant_id, model_type: str | enum.En return model_config else: - region = extra_fields.get("region", "default") - if region == "intl" and provider_name.lower() == "siliconflow": - target_factory_name = "siliconflow_intl" - else: - target_factory_name = provider_name - fac_list = [f for f in settings.FACTORY_LLM_INFOS if f["name"] == target_factory_name] - if not fac_list: - raise LookupError(f"Model provider config not found: {provider_name}") - llm_list = [llm for llm in fac_list[0]["llm"] if llm["llm_name"] == pure_model_name] - if not llm_list: - raise LookupError(f"Instance {instance_name} not found for model {model_name}.") - llm_info = llm_list[0] - if model_type_val not in _factory_model_types(llm_info): - raise LookupError(f"Model {model_name} is not a {model_type_val} model.") - model_config = { - "llm_factory": provider_obj.provider_name, - "api_key": api_key, - "llm_name": llm_info["llm_name"], - "api_base": extra_fields.get("base_url", ""), - "model_type": model_type_val, - "is_tools": llm_info.get("is_tools", is_tool), - "max_tokens": llm_info.get("max_tokens") or 8192, - } - if api_key_payload is not None: - model_config["api_key_payload"] = api_key_payload - return model_config + raise LookupError(f"Model {model_name} not found for model {model_type_val}") + + +def get_model_config_by_id(tenant_id: str, model_id: str): + """Get model config from tenant_model by its id (CharField PK).""" + exist, model_obj = TenantModelService.get_by_id(model_id) + if not exist: + raise LookupError(f"TenantModel id={model_id} not found.") + if model_obj.status != ActiveStatusEnum.ACTIVE.value: + raise LookupError(f"TenantModel id={model_id} is disabled.") + + provider_obj = TenantModelProviderService.get_by_id(model_obj.provider_id) + if not provider_obj: + raise LookupError(f"Provider id={model_obj.provider_id} not found for model id={model_id}.") + + # Validate that tenant_id owns the provider or is a joined tenant of the provider's owner. + if tenant_id != provider_obj.tenant_id: + joined_tenants = TenantService.get_joined_tenants_by_user_id(tenant_id) + joined_tenant_ids = [t["tenant_id"] for t in joined_tenants] + if provider_obj.tenant_id not in joined_tenant_ids: + raise LookupError(f"Tenant {tenant_id} has no access to provider owned by tenant {provider_obj.tenant_id}.") + + instance_obj = TenantModelInstanceService.get_by_id(model_obj.instance_id) + if not instance_obj: + raise LookupError(f"Instance id={model_obj.instance_id} not found for model id={model_id}.") + + api_key, is_tool, api_key_payload = _decode_api_key_config(instance_obj.api_key) + extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {} + model_extra = json.loads(model_obj.extra) if model_obj.extra else {} + + model_config = { + "llm_factory": provider_obj.provider_name, + "api_key": api_key, + "llm_name": model_obj.model_name, + "api_base": extra_fields.get("base_url", ""), + "model_type": model_obj.model_type, + "is_tools": model_extra.get("is_tools", is_tool), + "max_tokens": model_extra.get("max_tokens") or 8192, + } + if provider_obj.provider_name.lower() == "somark": + model_config["extra"] = model_extra + + if api_key_payload is not None: + model_config["api_key_payload"] = api_key_payload + + return model_config + + +def resolve_model_id(tenant_id: str, model_type: str | enum.Enum, model_name: str) -> str | None: + """Given a tenant_id, model_type and model_name (e.g. 'model@instance@provider'), + look up the corresponding tenant_model.id. Returns None if not found.""" + pure_model_name, instance_name, provider_name = split_model_name(model_name) + model_type_val = model_type if isinstance(model_type, str) else model_type.value + + # Builtin TEI embedding — no tenant_model row exists + compose_profiles = os.getenv("COMPOSE_PROFILES", "") + is_tei_builtin_embedding = ( + model_type_val == LLMType.EMBEDDING.value and "tei-" in compose_profiles and pure_model_name == os.getenv("TEI_MODEL", "") and (provider_name == "Builtin" or not provider_name) + ) + if is_tei_builtin_embedding: + return None + + if not provider_name: + raise LookupError(f"Provider name is required to resolve model id for {model_name}.") + + provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) + if not provider_obj: + raise LookupError(f"Provider {provider_name} not found for model {model_name}.") + + instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name) + model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_type_and_model_name( + provider_obj.id, instance_obj.id, model_type_val, pure_model_name + ) + if not model_obj: + raise LookupError(f"Model {model_name} not found for type {model_type_val}.") + return model_obj.id + + +# Mapping from model-name field → (LLMType, tenant_model id field) +_MODEL_NAME_TO_ID_FIELD_MAP: dict[str, tuple[str, str]] = { + "llm_id": (LLMType.CHAT, "tenant_llm_id"), + "embd_id": (LLMType.EMBEDDING, "tenant_embd_id"), + "rerank_id": (LLMType.RERANK, "tenant_rerank_id"), + "asr_id": (LLMType.SPEECH2TEXT, "tenant_asr_id"), + "img2txt_id": (LLMType.IMAGE2TEXT, "tenant_img2txt_id"), + "tts_id": (LLMType.TTS, "tenant_tts_id"), +} + + +def ensure_tenant_model_ids_for_params(tenant_id: str, params: dict) -> dict: + """For each model-name field present in *params*, resolve the corresponding + tenant_model id if the id field is not already present. + + Modifies *params* in-place (adds ``tenant_*_id`` keys) and returns it. + Silently skips resolution when the model is not found in tenant_model + (e.g. builtin TEI embedding). + + Typical usage at API entry points: + + req = await get_request_json() + ensure_tenant_model_ids_for_params(current_user.id, req) + # req now has tenant_llm_id / tenant_embd_id etc. filled in + """ + for name_field, (model_type, id_field) in _MODEL_NAME_TO_ID_FIELD_MAP.items(): + if name_field in params and id_field not in params: + try: + params[id_field] = resolve_model_id(tenant_id, model_type, params[name_field]) + except LookupError: + logger.debug("Could not resolve %s → %s for tenant %s, skipping", name_field, id_field, tenant_id) + return params def get_api_key(tenant_id: str, model_name: str): @@ -339,27 +438,13 @@ def get_model_type_by_name(tenant_id: str, model_name: str): provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) if not provider_obj: raise LookupError(f"Provider {provider_name} not found for model {model_name}.") - instance_obj = _resolve_instance_for_model(provider_obj, instance_name, model_name) - model_objs = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, pure_model_name) - types_in_json = [] - if not model_objs: - extra_fields = json.loads(instance_obj.extra) if instance_obj.extra else {} - region = extra_fields.get("region", "default") - if region == "intl" and provider_name.lower() == "siliconflow": - target_factory_name = "siliconflow_intl" - else: - target_factory_name = provider_name - fac_list = [f for f in settings.FACTORY_LLM_INFOS if f["name"] == target_factory_name] - if not fac_list: - raise LookupError(f"Model provider config not found: {provider_name}") - llm_list = [llm for llm in fac_list[0]["llm"] if llm["llm_name"] == pure_model_name] - if not llm_list: - raise LookupError(f"Model {pure_model_name} not found for model {model_name}.") - types_in_json = _factory_model_types(llm_list[0]) - return list( - set(types_in_json + [model_obj.model_type for model_obj in model_objs if model_obj.status != ActiveStatusEnum.UNSUPPORTED.value]) - - {model_obj.model_type for model_obj in model_objs if model_obj.status == ActiveStatusEnum.UNSUPPORTED.value} - ) + instance_obj = TenantModelInstanceService.get_by_provider_id_and_instance_name(provider_obj.id, instance_name) + if not instance_obj: + raise LookupError(f"Instance {instance_name} not found for model {model_name}.") + model_obj = TenantModelService.get_by_provider_id_and_instance_id_and_model_name(provider_obj.id, instance_obj.id, pure_model_name) + if not model_obj: + raise LookupError(f"Model {model_name} not found.") + return get_model_type_human(model_obj.model_type) def delete_models_by_instance_ids(instance_ids: list[str]): @@ -386,23 +471,3 @@ def ensure_somark_from_env(tenant_id: str) -> str | None: "somark-from-env", _collect_env_config(SOMARK_ENV_KEYS, SOMARK_DEFAULT_CONFIG), ) - - -def get_models_by_tenant_and_provider_and_model_type(tenant_id: str, provider_name: str, model_type: str): - """ - Query TenantModel records by tenant_id, provider_name and model_name. - Returns all matching model records under all instances of the specified provider. - """ - provider_obj = TenantModelProviderService.get_by_tenant_id_and_provider_name(tenant_id, provider_name) - if not provider_obj: - return [] - instances = TenantModelInstanceService.get_all_by_provider_id(provider_obj.id) - if not instances: - return [] - results = [] - for inst in instances: - models = TenantModelService.get_by_provider_id_and_instance_id_and_model_type(provider_obj.id, inst.id, model_type) - supported = [model for model in models if model.status != ActiveStatusEnum.UNSUPPORTED.value] - if supported: - results.extend(supported) - return results diff --git a/api/db/services/dialog_service.py b/api/db/services/dialog_service.py index 8d7eba49f4..f27c898108 100644 --- a/api/db/services/dialog_service.py +++ b/api/db/services/dialog_service.py @@ -39,7 +39,7 @@ from api.utils.reference_metadata_utils import ( enrich_chunks_with_document_metadata, resolve_reference_metadata_preferences, ) -from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance, get_model_type_by_name +from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance, get_model_type_by_name, get_model_config_by_id from common.time_utils import current_timestamp, datetime_format from common.text_utils import normalize_arabic_digits from rag.advanced_rag.knowlege_compile.mind_map_extractor import MindMapExtractor @@ -293,11 +293,25 @@ async def async_chat_solo(dialog, messages, stream=True, session_id=None): image_files = [] if dialog.llm_id: - llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) - if "chat" in llm_types: - model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + if dialog.tenant_llm_id: + try: + llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) + if "chat" in llm_types: + model_config = get_model_config_by_id(dialog.tenant_id, dialog.tenant_llm_id) + else: + model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) + except LookupError: + llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) + if "chat" in llm_types: + model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + else: + model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) else: - model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) + llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) + if "chat" in llm_types: + model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + else: + model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) else: model_config = get_tenant_default_model_by_type(dialog.tenant_id, LLMType.CHAT) @@ -356,14 +370,26 @@ def get_models(dialog, trace_context=None, langfuse_session_id=None): raise LookupError("Embedding model(%s) not found" % embedding_list[0]) if dialog.llm_id: - chat_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + if dialog.tenant_llm_id: + try: + chat_model_config = get_model_config_by_id(dialog.tenant_id, dialog.tenant_llm_id) + except LookupError: + chat_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + else: + chat_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) else: chat_model_config = get_tenant_default_model_by_type(dialog.tenant_id, LLMType.CHAT) chat_mdl = LLMBundle(dialog.tenant_id, chat_model_config, trace_context=trace_context, langfuse_session_id=langfuse_session_id) if dialog.rerank_id: - rerank_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.RERANK, dialog.rerank_id) + if dialog.tenant_rerank_id: + try: + rerank_model_config = get_model_config_by_id(dialog.tenant_id, dialog.tenant_rerank_id) + except LookupError: + rerank_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.RERANK, dialog.rerank_id) + else: + rerank_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.RERANK, dialog.rerank_id) rerank_mdl = LLMBundle(dialog.tenant_id, rerank_model_config, trace_context=trace_context, langfuse_session_id=langfuse_session_id) if dialog.prompt_config.get("tts"): @@ -555,11 +581,25 @@ async def async_chat(dialog, messages, stream=True, **kwargs): chat_start_ts = timer() if dialog.llm_id: - llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) - if "chat" in llm_types: - llm_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + if dialog.tenant_llm_id: + try: + llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) + if "chat" in llm_types: + llm_model_config = get_model_config_by_id(dialog.tenant_id, dialog.tenant_llm_id) + else: + llm_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) + except LookupError: + llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) + if "chat" in llm_types: + llm_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + else: + llm_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) else: - llm_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) + llm_types = get_model_type_by_name(dialog.tenant_id, dialog.llm_id) + if "chat" in llm_types: + llm_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.CHAT, dialog.llm_id) + else: + llm_model_config = get_model_config_from_provider_instance(dialog.tenant_id, LLMType.IMAGE2TEXT, dialog.llm_id) else: llm_model_config = get_tenant_default_model_by_type(dialog.tenant_id, LLMType.CHAT) diff --git a/api/db/services/memory_service.py b/api/db/services/memory_service.py index 97e5007d86..495f0fb1c8 100644 --- a/api/db/services/memory_service.py +++ b/api/db/services/memory_service.py @@ -109,7 +109,8 @@ class MemoryService(CommonService): @classmethod @DB.connection_context() - def create_memory(cls, tenant_id: str, name: str, memory_type: List[str], embd_id: str, llm_id: str): + def create_memory(cls, tenant_id: str, name: str, memory_type: List[str], embd_id: str, llm_id: str, + tenant_embd_id: str | None = None, tenant_llm_id: str | None = None): # Deduplicate name within tenant memory_name = duplicate_name(cls.query, name=name, tenant_id=tenant_id) if len(memory_name) > MEMORY_NAME_LIMIT: @@ -124,7 +125,9 @@ class MemoryService(CommonService): "memory_type": calculate_memory_type(memory_type), "tenant_id": tenant_id, "embd_id": embd_id, + "tenant_embd_id": tenant_embd_id, "llm_id": llm_id, + "tenant_llm_id": tenant_llm_id, "system_prompt": PromptAssembler.assemble_system_prompt({"memory_type": memory_type}), "create_time": timestamp, "create_date": format_time, diff --git a/api/db/services/task_service.py b/api/db/services/task_service.py index 2e02c50f44..6d9c7d197f 100644 --- a/api/db/services/task_service.py +++ b/api/db/services/task_service.py @@ -197,11 +197,13 @@ class TaskService(CommonService): Knowledgebase.tenant_id, Knowledgebase.language, Knowledgebase.embd_id, + Knowledgebase.tenant_embd_id, Knowledgebase.pagerank, Knowledgebase.parser_config.alias("kb_parser_config"), Tenant.img2txt_id, Tenant.asr_id, Tenant.llm_id, + Tenant.tenant_llm_id, cls.model.update_time, ] docs = ( diff --git a/api/db/services/tenant_model_service.py b/api/db/services/tenant_model_service.py index 98dc6e892a..d8274d8ab2 100644 --- a/api/db/services/tenant_model_service.py +++ b/api/db/services/tenant_model_service.py @@ -13,9 +13,9 @@ # See the License for the specific language governing permissions and # limitations under the License. # -from common.constants import ActiveStatusEnum from api.db.db_models import DB, TenantModel from api.db.services.common_service import CommonService +from api.utils.model_utils import calculate_model_type class TenantModelService(CommonService): @@ -24,17 +24,30 @@ class TenantModelService(CommonService): @classmethod @DB.connection_context() def get_by_provider_id_and_instance_id_and_model_name(cls, provider_id, instance_id, model_name): - return list(cls.model.select().where(cls.model.provider_id == provider_id, cls.model.instance_id == instance_id, cls.model.model_name == model_name)) + return cls.model.get_or_none(cls.model.provider_id == provider_id, cls.model.instance_id == instance_id, cls.model.model_name == model_name) @classmethod @DB.connection_context() def get_by_provider_id_and_instance_id_and_model_type_and_model_name(cls, provider_id, instance_id, model_type, model_name): - return cls.model.get_or_none(cls.model.provider_id == provider_id, cls.model.instance_id == instance_id, cls.model.model_type == model_type, cls.model.model_name == model_name) + if isinstance(model_type, str): + model_type = calculate_model_type([model_type]) + return cls.model.get_or_none( + cls.model.provider_id == provider_id, + cls.model.instance_id == instance_id, + cls.model.model_type.bin_and(model_type) > 0, + cls.model.model_name == model_name + ) @classmethod @DB.connection_context() def get_by_provider_id_and_instance_id_and_model_type(cls, provider_id, instance_id, model_type): - return cls.model.get_or_none(cls.model.provider_id == provider_id, cls.model.instance_id == instance_id, cls.model.model_type == model_type) + if isinstance(model_type, str): + model_type = calculate_model_type([model_type]) + return cls.model.get_or_none( + cls.model.provider_id == provider_id, + cls.model.instance_id == instance_id, + cls.model.model_type.bin_and(model_type) > 0 + ) @classmethod @DB.connection_context() @@ -53,33 +66,17 @@ class TenantModelService(CommonService): @classmethod @DB.connection_context() - def upsert_model_type(cls, provider_id: str, instance_id: str, model_name: str, operation: dict): - model_type_records = cls.model.select().where(cls.model.provider_id == provider_id, cls.model.instance_id == instance_id, cls.model.model_name == model_name) - if not model_type_records: - for _type in operation.get("add", []): - cls.insert(model_name=model_name, provider_id=provider_id, instance_id=instance_id, model_type=_type, extra="{}") - for _type in operation.get("delete", []): - cls.insert(model_name=model_name, provider_id=provider_id, instance_id=instance_id, model_type=_type, status=ActiveStatusEnum.UNSUPPORTED, extra="{}") - return len(operation.get("add", [])) + len(operation.get("delete", [])) - model_record_example = [model_record for model_record in model_type_records if model_record.status != ActiveStatusEnum.UNSUPPORTED.value] - extra_fields = model_record_example[0].extra if model_record_example else "{}" - model_status = model_record_example[0].status if model_record_example else ActiveStatusEnum.ACTIVE.value - type_record_map = {record.model_type: record for record in model_type_records} - operated_cnt = 0 - for _type in operation.get("add", []): - if type_record_map.get(_type): - cls.update_by_id(type_record_map[_type].id, {"status": model_status}) + def update_model(cls, model_id, update_dict): + return cls.model.update(**update_dict).where(cls.model.id == model_id).execute() - else: - cls.insert(model_name=model_name, provider_id=provider_id, instance_id=instance_id, model_type=_type, status=model_status, extra=extra_fields) - operated_cnt += 1 - for _type in operation.get("delete", []): - if type_record_map.get(_type): - cls.update_by_id(type_record_map[_type].id, {"status": ActiveStatusEnum.UNSUPPORTED.value}) - else: - cls.insert(model_name=model_name, provider_id=provider_id, instance_id=instance_id, model_type=_type, status=ActiveStatusEnum.UNSUPPORTED.value, extra=extra_fields) - operated_cnt += 1 - return operated_cnt + @classmethod + @DB.connection_context() + def batch_update_model_type(cls, model_ids, model_type): + if isinstance(model_type, str): + model_type = calculate_model_type([model_type]) + elif isinstance(model_type, list): + model_type = calculate_model_type(model_type) + return cls.model.update(model_type=model_type).where(cls.model.id.in_(model_ids)).execute() @classmethod @DB.connection_context() diff --git a/api/utils/model_utils.py b/api/utils/model_utils.py new file mode 100644 index 0000000000..f28dc37926 --- /dev/null +++ b/api/utils/model_utils.py @@ -0,0 +1,33 @@ +# +# Copyright 2025 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +from typing import List + +from common.constants import ModelTypeBinary + + +def get_model_type_human(model_type: int) -> List[str]: + return [mt.name.lower() for mt in ModelTypeBinary if model_type & mt.value] + + +def calculate_model_type(model_type_name_list: List[str]|str) -> int: + model_type = 0 + if isinstance(model_type_name_list, str): + model_type_name_list = [model_type_name_list] + type_value_map = {mt.name.lower(): mt.value for mt in ModelTypeBinary} + for mt in model_type_name_list: + if mt in type_value_map: + model_type |= type_value_map[mt] + return model_type diff --git a/api/utils/validation_utils.py b/api/utils/validation_utils.py index d20ae4fbbe..fdbc31889a 100644 --- a/api/utils/validation_utils.py +++ b/api/utils/validation_utils.py @@ -942,7 +942,7 @@ class SearchDatasetReq(BaseModel): keyword: Annotated[bool, Field(default=False)] search_id: Annotated[str | None, Field(default=None)] rerank_id: Annotated[str | None, Field(default=None)] - tenant_rerank_id: Annotated[int | None, Field(default=None)] + tenant_rerank_id: Annotated[str | None, Field(default=None)] meta_data_filter: Annotated[dict | None, Field(default=None)] @@ -964,7 +964,7 @@ class SearchDatasetsReq(BaseModel): keyword: Annotated[bool, Field(default=False)] search_id: Annotated[str | None, Field(default=None)] rerank_id: Annotated[str | None, Field(default=None)] - tenant_rerank_id: Annotated[int | None, Field(default=None)] + tenant_rerank_id: Annotated[str | None, Field(default=None)] meta_data_filter: Annotated[dict | None, Field(default=None)] diff --git a/common/constants.py b/common/constants.py index 0c66d39ab2..3c1dd63993 100644 --- a/common/constants.py +++ b/common/constants.py @@ -72,6 +72,12 @@ class ActiveStatusEnum(Enum): UNSUPPORTED = "unsupported" +class ModelVerifyStatusEnum(Enum): + SUCCESS = "success" + FAIL = "fail" + UNKNOWN = "unknown" + + class ActiveEnum(Enum): ACTIVE = "1" INACTIVE = "0" @@ -87,6 +93,16 @@ class LLMType(StrEnum): OCR = "ocr" +class ModelTypeBinary(Enum): + CHAT = 0b0000001 # 1 << 0 = 1 + EMBEDDING = 0b0000010 # 1 << 1 = 2 + SPEECH2TEXT = 0b0000100 # 1 << 2 = 4 + IMAGE2TEXT = 0b0001000 # 1 << 3 = 8 + RERANK = 0b0010000 # 1 << 4 = 16 + TTS = 0b0100000 # 1 << 5 = 32 + OCR = 0b1000000 # 1 << 6 = 64 + + class TaskStatus(StrEnum): UNSTART = "0" RUNNING = "1" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 22339d829f..d7ef35b11d 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -255,14 +255,7 @@ ensure_docling ensure_db_init if [[ "${INIT_MODEL_PROVIDER_TABLES}" -eq 1 ]]; then - echo "Running model provider table migrations..." - "$PY" tools/scripts/mysql_migration.py \ - --stages tenant_model_provider,tenant_model_instance,tenant_model,model_id_config \ - --config conf/service_conf.yaml \ - --execute \ - --database-version "v0.26.1" \ - --mark-database-version-on-success - echo "Model provider table migrations completed." + tools/scripts/run_migrations.sh fi if [[ "${ENABLE_ADMIN_SERVER}" -eq 1 ]]; then diff --git a/docker/launch_backend_service.sh b/docker/launch_backend_service.sh index a831e54664..ba7a519c0c 100755 --- a/docker/launch_backend_service.sh +++ b/docker/launch_backend_service.sh @@ -200,14 +200,7 @@ ensure_db_init() { } run_mysql_migrations() { - echo "Running model provider table migrations..." - "$PY" tools/scripts/mysql_migration.py \ - --stages tenant_model_provider,tenant_model_instance,tenant_model,model_id_config \ - --config conf/service_conf.yaml \ - --execute \ - --database-version "v0.26.1" \ - --mark-database-version-on-success - echo "Model provider table migrations completed." + tools/scripts/run_migrations.sh } prepare_for_go() { diff --git a/rag/flow/tokenizer/tokenizer.py b/rag/flow/tokenizer/tokenizer.py index 20d0fe5826..63b9df0f96 100644 --- a/rag/flow/tokenizer/tokenizer.py +++ b/rag/flow/tokenizer/tokenizer.py @@ -21,7 +21,7 @@ import numpy as np from common.constants import LLMType from api.db.services.knowledgebase_service import KnowledgebaseService from api.db.services.llm_service import LLMBundle -from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance +from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance, get_model_config_by_id from common.connection_utils import timeout from rag.flow.base import ProcessBase, ProcessParamBase from rag.flow.parser.pdf_chunk_metadata import finalize_pdf_chunk @@ -61,7 +61,13 @@ class Tokenizer(ProcessBase): token_count = 0 if self._canvas._kb_id: e, kb = KnowledgebaseService.get_by_id(self._canvas._kb_id) - embd_model_config = get_model_config_from_provider_instance(self._canvas._tenant_id, LLMType.EMBEDDING, kb.embd_id) + if kb.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id(self._canvas._tenant_id, kb.tenant_embd_id) + except LookupError: + embd_model_config = get_model_config_from_provider_instance(self._canvas._tenant_id, LLMType.EMBEDDING, kb.embd_id) + else: + embd_model_config = get_model_config_from_provider_instance(self._canvas._tenant_id, LLMType.EMBEDDING, kb.embd_id) else: embd_model_config = get_tenant_default_model_by_type(self._canvas._tenant_id, LLMType.EMBEDDING) embedding_model = LLMBundle(self._canvas._tenant_id, embd_model_config) diff --git a/rag/graphrag/general/smoke.py b/rag/graphrag/general/smoke.py index dc84b2fbbe..687ef2ba47 100644 --- a/rag/graphrag/general/smoke.py +++ b/rag/graphrag/general/smoke.py @@ -24,7 +24,7 @@ from common.constants import LLMType from api.db.services.document_service import DocumentService from api.db.services.knowledgebase_service import KnowledgebaseService from api.db.services.llm_service import LLMBundle -from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance +from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance, get_model_config_by_id from rag.graphrag.general.graph_extractor import GraphExtractor from rag.graphrag.general.index import update_graph, with_resolution, with_community from common import settings @@ -74,7 +74,13 @@ async def main(): llm_config = get_tenant_default_model_by_type(args.tenant_id, LLMType.CHAT) llm_bdl = LLMBundle(args.tenant_id, llm_config) _, kb = KnowledgebaseService.get_by_id(kb_id) - embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) + if kb.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id(args.tenant_id, kb.tenant_embd_id) + except LookupError: + embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) + else: + embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) embed_bdl = LLMBundle(args.tenant_id, embd_model_config) graph, doc_ids = await update_graph( diff --git a/rag/graphrag/light/smoke.py b/rag/graphrag/light/smoke.py index 4eeb254a6a..0084aca377 100644 --- a/rag/graphrag/light/smoke.py +++ b/rag/graphrag/light/smoke.py @@ -24,7 +24,7 @@ from common.constants import LLMType from api.db.services.document_service import DocumentService from api.db.services.knowledgebase_service import KnowledgebaseService from api.db.services.llm_service import LLMBundle -from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance +from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance, get_model_config_by_id from rag.graphrag.general.index import update_graph from rag.graphrag.light.graph_extractor import GraphExtractor from common import settings @@ -75,7 +75,13 @@ async def main(): llm_config = get_tenant_default_model_by_type(args.tenant_id, LLMType.CHAT) llm_bdl = LLMBundle(args.tenant_id, llm_config) _, kb = KnowledgebaseService.get_by_id(kb_id) - embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) + if kb.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id(args.tenant_id, kb.tenant_embd_id) + except LookupError: + embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) + else: + embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) embed_bdl = LLMBundle(args.tenant_id, embd_model_config) graph, doc_ids = await update_graph( diff --git a/rag/graphrag/search.py b/rag/graphrag/search.py index 34cf0a39d3..70d846a17e 100644 --- a/rag/graphrag/search.py +++ b/rag/graphrag/search.py @@ -300,7 +300,7 @@ if __name__ == "__main__": from common.constants import LLMType from api.db.services.knowledgebase_service import KnowledgebaseService from api.db.services.llm_service import LLMBundle - from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance + from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance, get_model_config_by_id from rag.nlp import search settings.init_settings() @@ -314,7 +314,13 @@ if __name__ == "__main__": llm_config = get_tenant_default_model_by_type(args.tenant_id, LLMType.CHAT) llm_bdl = LLMBundle(args.tenant_id, llm_config) _, kb = KnowledgebaseService.get_by_id(kb_id) - embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) + if kb.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id(args.tenant_id, kb.tenant_embd_id) + except LookupError: + embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) + else: + embd_model_config = get_model_config_from_provider_instance(args.tenant_id, LLMType.EMBEDDING, kb.embd_id) embed_bdl = LLMBundle(args.tenant_id, embd_model_config) kg = KGSearch(settings.docStoreConn) diff --git a/rag/llm/ocr_model.py b/rag/llm/ocr_model.py index d31cd27099..e9e589b27f 100644 --- a/rag/llm/ocr_model.py +++ b/rag/llm/ocr_model.py @@ -286,8 +286,8 @@ class SoMarkOcrModel(Base, SoMarkParser): redacted_config[k] = v logging.info(f"Parsed SoMark config (sensitive fields redacted): {redacted_config}") - self.api_key = api_key self.base_url = base_url + self.api_key = api_key SoMarkParser.__init__( self, base_url=base_url, diff --git a/rag/svr/task_executor.py b/rag/svr/task_executor.py index 2e85ef7e82..2f367754e4 100644 --- a/rag/svr/task_executor.py +++ b/rag/svr/task_executor.py @@ -79,7 +79,7 @@ from api.db.services.doc_metadata_service import DocMetadataService from api.db.services.llm_service import LLMBundle from api.db.services.task_service import TaskService, has_canceled, CANVAS_DEBUG_DOC_ID, GRAPH_RAPTOR_FAKE_DOC_ID from api.db.services.file2document_service import File2DocumentService -from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance +from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance, get_model_config_by_id from common.versions import get_ragflow_version from api.db.db_models import close_connection from rag.app import laws, paper, presentation, manual, qa, table, book, resume, picture, naive, one, audio, email, tag @@ -811,7 +811,13 @@ async def run_dataflow(task: dict): set_progress(task_id, prog=0.82, msg="\n-------------------------------------\nStart to embedding...") e, kb = KnowledgebaseService.get_by_id(task["kb_id"]) embedding_id = kb.embd_id - embd_model_config = get_model_config_from_provider_instance(task["tenant_id"], LLMType.EMBEDDING, embedding_id) + if kb.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id(task["tenant_id"], kb.tenant_embd_id) + except LookupError: + embd_model_config = get_model_config_from_provider_instance(task["tenant_id"], LLMType.EMBEDDING, embedding_id) + else: + embd_model_config = get_model_config_from_provider_instance(task["tenant_id"], LLMType.EMBEDDING, embedding_id) embedding_model = LLMBundle(task["tenant_id"], embd_model_config) @timeout(60) diff --git a/rag/svr/task_executor_refactor/dataflow_service.py b/rag/svr/task_executor_refactor/dataflow_service.py index 79b396d7a8..355fc02b53 100644 --- a/rag/svr/task_executor_refactor/dataflow_service.py +++ b/rag/svr/task_executor_refactor/dataflow_service.py @@ -38,7 +38,7 @@ from api.db.services.canvas_service import UserCanvasService from api.db.services.document_service import DocumentService from api.db.services.doc_metadata_service import DocMetadataService from api.db.services.pipeline_operation_log_service import PipelineOperationLogService -from api.db.joint_services.tenant_model_service import get_model_config_from_provider_instance +from api.db.joint_services.tenant_model_service import get_model_config_from_provider_instance, get_model_config_by_id from common.connection_utils import timeout from common.constants import LLMType, PipelineTaskType from common.metadata_utils import update_metadata_to @@ -235,7 +235,17 @@ class DataflowService: self._progress(prog=0.82, msg="\n-------------------------------------\nStart to embedding...") e, kb = self._get_kb_by_id(ctx.kb_id) embedding_id = kb.embd_id - embd_model_config = get_model_config_from_provider_instance(ctx.tenant_id, LLMType.EMBEDDING, embedding_id) + if kb.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id(ctx.tenant_id, kb.tenant_embd_id) + except LookupError: + embd_model_config = get_model_config_from_provider_instance( + ctx.tenant_id, LLMType.EMBEDDING, embedding_id + ) + else: + embd_model_config = get_model_config_from_provider_instance( + ctx.tenant_id, LLMType.EMBEDDING, embedding_id + ) from api.db.services.llm_service import LLMBundle with LLMBundle(ctx.tenant_id, embd_model_config) as embedding_model: diff --git a/rag/svr/task_executor_refactor/task_context.py b/rag/svr/task_executor_refactor/task_context.py index dd52de26b3..787f267964 100644 --- a/rag/svr/task_executor_refactor/task_context.py +++ b/rag/svr/task_executor_refactor/task_context.py @@ -113,9 +113,15 @@ class TaskDict(TypedDict, total=False): llm_id: str """LLM model identifier.""" + tenant_llm_id: str | None + """Tenant model ID for LLM (id in tenant_model table).""" + embd_id: str """Embedding model identifier.""" + tenant_embd_id: str | None + """Tenant model ID for embedding (id in tenant_model table).""" + from_page: int """Starting page number for processing (0-based).""" @@ -227,7 +233,9 @@ class TaskContext: "kb_parser_config": {}, "language": "Chinese", "llm_id": "", + "tenant_llm_id": "", "embd_id": "", + "tenant_embd_id": "", "from_page": 0, "to_page": -1, "task_type": "", @@ -361,11 +369,21 @@ class TaskContext: """LLM model identifier.""" return self._task.get("llm_id", self._DEFAULTS["llm_id"]) + @property + def tenant_llm_id(self) -> str | None: + """Tenant model ID for LLM (id in tenant_model table).""" + return self._task.get("tenant_llm_id", self._DEFAULTS["tenant_llm_id"]) or None + @property def embd_id(self) -> str: """Embedding model identifier.""" return self._task.get("embd_id", self._DEFAULTS["embd_id"]) + @property + def tenant_embd_id(self) -> str | None: + """Tenant model ID for embedding (id in tenant_model table).""" + return self._task.get("tenant_embd_id", self._DEFAULTS["tenant_embd_id"]) or None + # ========================================================================= # Page range properties # ========================================================================= diff --git a/rag/svr/task_executor_refactor/task_handler.py b/rag/svr/task_executor_refactor/task_handler.py index cce68a985f..f754a2fcce 100644 --- a/rag/svr/task_executor_refactor/task_handler.py +++ b/rag/svr/task_executor_refactor/task_handler.py @@ -40,7 +40,11 @@ from api.db.services.document_service import DocumentService from api.db.services.knowledgebase_service import KnowledgebaseService from api.db.services.compilation_template_group_service import CompilationTemplateGroupService from api.db.joint_services.memory_message_service import handle_save_to_memory_task -from api.db.joint_services.tenant_model_service import get_tenant_default_model_by_type, get_model_config_from_provider_instance +from api.db.joint_services.tenant_model_service import ( + get_tenant_default_model_by_type, + get_model_config_from_provider_instance, + get_model_config_by_id, +) from api.db.services.llm_service import LLMBundle from api.db.services.task_service import GRAPH_RAPTOR_FAKE_DOC_ID, abort_doc_chunking_counter from common.constants import LLMType @@ -315,8 +319,19 @@ class TaskHandler: task_language = ctx.language try: - if task_embedding_id: - embd_model_config = get_model_config_from_provider_instance(task_tenant_id, LLMType.EMBEDDING, task_embedding_id) + if ctx.tenant_embd_id: + try: + embd_model_config = get_model_config_by_id( + task_tenant_id, ctx.tenant_embd_id + ) + except LookupError: + embd_model_config = get_model_config_from_provider_instance( + task_tenant_id, LLMType.EMBEDDING, task_embedding_id + ) + elif task_embedding_id: + embd_model_config = get_model_config_from_provider_instance( + task_tenant_id, LLMType.EMBEDDING, task_embedding_id + ) else: embd_model_config = get_tenant_default_model_by_type(task_tenant_id, LLMType.EMBEDDING) embedding_model = LLMBundle(task_tenant_id, embd_model_config, lang=task_language) diff --git a/test/testcases/restful_api/test_datasets.py b/test/testcases/restful_api/test_datasets.py index c898715dca..3f951478c4 100644 --- a/test/testcases/restful_api/test_datasets.py +++ b/test/testcases/restful_api/test_datasets.py @@ -808,7 +808,7 @@ def test_dataset_update_embedding_model_invalid_and_none_contract(rest_client, c dataset_id = create_payload["data"]["id"] invalid_cases = [ - ("unknown@ZHIPU-AI", "Instance default not found for model unknown@ZHIPU-AI."), + ("unknown@ZHIPU-AI", "Model unknown@ZHIPU-AI not found for model embedding"), ("embedding-3@unknown", "Provider unknown not found for model embedding-3@unknown."), ("text-embedding-v3@Tongyi-Qianwen", "Provider Tongyi-Qianwen not found for model text-embedding-v3@Tongyi-Qianwen."), ("text-embedding-3-small@OpenAI", "Provider OpenAI not found for model text-embedding-3-small@OpenAI."), @@ -1163,7 +1163,7 @@ def test_dataset_create_permission_contract(rest_client, clear_datasets, name, p ("tenant_zhipu", "embedding-3@CI@ZHIPU-AI", 0, "embedding-3@CI@ZHIPU-AI", None, True), ("embedding_model_unset", "__UNSET__", 0, "BAAI/bge-small-en-v1.5@Local@Builtin", None, False), ("embedding_model_none", None, 0, "BAAI/bge-small-en-v1.5@Local@Builtin", None, False), - ("unknown_llm_name", "unknown@ZHIPU-AI", 102, None, "Instance default not found for model unknown@ZHIPU-AI.", False), + ("unknown_llm_name", "unknown@ZHIPU-AI", 102, None, "Model unknown@ZHIPU-AI not found for model embedding", False), ("unknown_llm_factory", "embedding-3@unknown", 102, None, "Provider unknown not found for model embedding-3@unknown.", False), ( "tenant_no_auth_default_tenant_llm", diff --git a/test/testcases/restful_api/test_dify_retrieval_routes_unit.py b/test/testcases/restful_api/test_dify_retrieval_routes_unit.py index 83048a425f..95650fb2f3 100644 --- a/test/testcases/restful_api/test_dify_retrieval_routes_unit.py +++ b/test/testcases/restful_api/test_dify_retrieval_routes_unit.py @@ -45,7 +45,7 @@ class _AwaitableValue: class _DummyKB: - def __init__(self, tenant_id="tenant-1", embd_id="embd-1", tenant_embd_id=1): + def __init__(self, tenant_id="tenant-1", embd_id="embd-1", tenant_embd_id="tm-embd-1"): self.tenant_id = tenant_id self.embd_id = embd_id self.tenant_embd_id = tenant_embd_id @@ -212,7 +212,7 @@ def _load_dify_retrieval_module(monkeypatch): "id": self.id, } - def _get_model_config_by_id(tenant_model_id: int, allowed_tenant_ids=None, requester_tenant_id=None) -> dict: + def _get_model_config_by_id(tenant_model_id: str, allowed_tenant_ids=None, requester_tenant_id=None) -> dict: mock_tenant_id = "tenant-1" if allowed_tenant_ids is not None: if isinstance(allowed_tenant_ids, str): diff --git a/test/testcases/test_http_api/test_dataset_management/test_dify_retrieval_routes_unit.py b/test/testcases/test_http_api/test_dataset_management/test_dify_retrieval_routes_unit.py index a8a3b75a91..9573eab268 100644 --- a/test/testcases/test_http_api/test_dataset_management/test_dify_retrieval_routes_unit.py +++ b/test/testcases/test_http_api/test_dataset_management/test_dify_retrieval_routes_unit.py @@ -45,7 +45,7 @@ class _AwaitableValue: class _DummyKB: - def __init__(self, tenant_id="tenant-1", embd_id="embd-1", tenant_embd_id=1): + def __init__(self, tenant_id="tenant-1", embd_id="embd-1", tenant_embd_id="tm-embd-1"): self.tenant_id = tenant_id self.embd_id = embd_id self.tenant_embd_id = tenant_embd_id @@ -221,7 +221,7 @@ def _load_dify_retrieval_module(monkeypatch): } def _get_model_config_by_id( - tenant_model_id: int, + tenant_model_id: str, allowed_tenant_ids=None, requester_tenant_id=None, ) -> dict: diff --git a/test/testcases/test_http_api/test_file_management_within_dataset/test_doc_sdk_routes_unit.py b/test/testcases/test_http_api/test_file_management_within_dataset/test_doc_sdk_routes_unit.py index 6620a102e7..b21f3db6b7 100644 --- a/test/testcases/test_http_api/test_file_management_within_dataset/test_doc_sdk_routes_unit.py +++ b/test/testcases/test_http_api/test_file_management_within_dataset/test_doc_sdk_routes_unit.py @@ -447,7 +447,7 @@ def _load_doc_module(monkeypatch, module_basename="chunk_api"): } def _get_model_config_by_id( - tenant_model_id: int, + tenant_model_id: str, allowed_tenant_ids=None, requester_tenant_id=None, ) -> dict: @@ -989,7 +989,7 @@ class TestDocRoutesUnit: monkeypatch.setattr(module.rag_tokenizer, "fine_grained_tokenize", lambda text: text or "") monkeypatch.setattr(module.rag_tokenizer, "is_chinese", lambda _text: False) monkeypatch.setattr(module.DocumentService, "get_embd_id", lambda _doc_id: "embd") - monkeypatch.setattr(module.DocumentService, "get_tenant_embd_id", lambda _doc_id: 1) + monkeypatch.setattr(module.DocumentService, "get_tenant_embd_id", lambda _doc_id: "tm-embd-1") class _EmbedModel: def encode(self, _texts): @@ -1079,8 +1079,8 @@ class TestDocRoutesUnit: "get_request_json", lambda: _AwaitableValue({"dataset_ids": ["ds-1"], "question": "q", "highlight": "True"}), ) - monkeypatch.setattr(module.KnowledgebaseService, "get_by_ids", lambda _ids: [SimpleNamespace(embd_id="m1", tenant_id="tenant-1", tenant_embd_id=1)]) - monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, SimpleNamespace(tenant_id="tenant-1", embd_id="m1", tenant_embd_id=1))) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_ids", lambda _ids: [SimpleNamespace(embd_id="m1", tenant_id="tenant-1", tenant_embd_id="tm-embd-1")]) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, SimpleNamespace(tenant_id="tenant-1", embd_id="m1", tenant_embd_id="tm-embd-1"))) class _Retriever: async def retrieval(self, *_args, **_kwargs): @@ -1203,7 +1203,7 @@ class TestDocRoutesUnit: } ), ) - monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, SimpleNamespace(tenant_id="tenant-1", embd_id="m1", tenant_embd_id=1))) + monkeypatch.setattr(module.KnowledgebaseService, "get_by_id", lambda _id: (True, SimpleNamespace(tenant_id="tenant-1", embd_id="m1", tenant_embd_id="tm-embd-1"))) monkeypatch.setattr(module, "cross_languages", _cross_languages) monkeypatch.setattr(module, "keyword_extraction", _keyword_extraction) monkeypatch.setattr(module.settings, "retriever", _FeatureRetriever()) diff --git a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py index 4fec9309d8..bca801dfd9 100644 --- a/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py +++ b/test/testcases/test_http_api/test_session_management/test_session_sdk_routes_unit.py @@ -455,7 +455,7 @@ def _load_session_module(monkeypatch): } def _get_model_config_by_id( - tenant_model_id: int, + tenant_model_id: str, allowed_tenant_ids=None, requester_tenant_id=None, ) -> dict: diff --git a/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py b/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py index 38c0e3956c..d483c61860 100644 --- a/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py +++ b/test/testcases/test_web_api/test_chunk_app/test_chunk_routes_unit.py @@ -345,7 +345,7 @@ def _load_chunk_module(monkeypatch): @staticmethod def get_tenant_embd_id(_doc_id): - return 1 + return "tm-embd-1" @staticmethod def decrement_chunk_num(*args): @@ -377,7 +377,7 @@ def _load_chunk_module(monkeypatch): @staticmethod def get_by_id(_kb_id): - return True, SimpleNamespace(pagerank=0.6, tenant_id="tenant-1", tenant_embd_id=2, tenant_llm_id=1) + return True, SimpleNamespace(pagerank=0.6, tenant_id="tenant-1", tenant_embd_id="tm-embd-2", tenant_llm_id="tm-llm-1") kb_service_mod.KnowledgebaseService = _KnowledgebaseService monkeypatch.setitem(sys.modules, "api.db.services.knowledgebase_service", kb_service_mod) @@ -451,9 +451,9 @@ def _load_chunk_module(monkeypatch): def get_by_id(tenant_id): return True, SimpleNamespace( llm_id="gpt-3.5-turbo", - tenant_llm_id=1, + tenant_llm_id="tm-llm-1", embd_id="text-embedding-ada-002", - tenant_embd_id=2, + tenant_embd_id="tm-embd-2", asr_id="whisper-1", img2txt_id="gpt-4-vision-preview", rerank_id="bge-reranker", diff --git a/test/unit_test/api/apps/services/test_models_api_service_list_tenant_added_models.py b/test/unit_test/api/apps/services/test_models_api_service_list_tenant_added_models.py index d1db0d34de..48c04baaac 100644 --- a/test/unit_test/api/apps/services/test_models_api_service_list_tenant_added_models.py +++ b/test/unit_test/api/apps/services/test_models_api_service_list_tenant_added_models.py @@ -24,6 +24,7 @@ import importlib.util import logging import sys from pathlib import Path +from enum import IntEnum from types import ModuleType, SimpleNamespace from unittest.mock import MagicMock @@ -32,6 +33,20 @@ import pytest pytestmark = pytest.mark.p2 +class _StubModelTypeBinary(IntEnum): + """Mimics common.constants.ModelTypeBinary for the stubbed environment.""" + CHAT = 1 + EMBEDDING = 2 + SPEECH2TEXT = 4 + IMAGE2TEXT = 8 + RERANK = 16 + TTS = 32 + OCR = 64 + + +_MODEL_TYPE_TO_BIN = {mt.name.lower(): mt.value for mt in _StubModelTypeBinary} + + def _stub(monkeypatch, name, **attrs): """Register a stub module in `sys.modules` with the given attributes. @@ -120,6 +135,9 @@ def _load_module(monkeypatch, *, tenant_model_records, factory_llm_infos=None): get_by_provider_id_and_instance_id_and_model_type_and_model_name=lambda *args: SimpleNamespace( status=1 ), + get_by_provider_id_and_instance_id_and_model_name=lambda *args: SimpleNamespace( + status=1 + ), ), ) @@ -143,6 +161,7 @@ def _load_module(monkeypatch, *, tenant_model_records, factory_llm_infos=None): "common.constants", ActiveStatusEnum=SimpleNamespace(ACTIVE=SimpleNamespace(value=1), INACTIVE=SimpleNamespace(value=0), UNSUPPORTED=SimpleNamespace(value=2)), LLMType=SimpleNamespace(EMBEDDING="embedding"), + ModelTypeBinary=_StubModelTypeBinary, ) _stub( monkeypatch, @@ -183,18 +202,21 @@ def _make_model_record(model_name, model_type="embedding", status=1): Args: model_name: Model name; may contain `@` characters. - model_type: Model type filter (default `embedding`). - status: `ActiveStatusEnum` value (default `1` = ACTIVE). + model_type: Model type string (default `embedding`) or int bitmask. + String values are automatically converted to the corresponding + bitmask so that `record.model_type & filter_bin` works. + status: `ActiveStatusEnum` value (default `1` = ACTIVE in stub). Returns: A `SimpleNamespace` with the fields read by `list_tenant_added_models`. """ + model_type_bin = _MODEL_TYPE_TO_BIN.get(model_type, model_type) if isinstance(model_type, str) else model_type return SimpleNamespace( provider_id="provider-1", instance_id="instance-1", model_name=model_name, - model_type=model_type, + model_type=model_type_bin, status=status, ) diff --git a/test/unit_test/api/db/services/test_dialog_service_final_answer.py b/test/unit_test/api/db/services/test_dialog_service_final_answer.py index be1062e348..6234fc10c6 100644 --- a/test/unit_test/api/db/services/test_dialog_service_final_answer.py +++ b/test/unit_test/api/db/services/test_dialog_service_final_answer.py @@ -381,6 +381,7 @@ def _make_dialog(chat_mdl_stub): top_n=6, top_k=1024, rerank_id="", + tenant_rerank_id=None ) diff --git a/test/unit_test/api/db/services/test_dialog_service_use_sql_source_columns.py b/test/unit_test/api/db/services/test_dialog_service_use_sql_source_columns.py index aee44eff32..5da77c6615 100644 --- a/test/unit_test/api/db/services/test_dialog_service_use_sql_source_columns.py +++ b/test/unit_test/api/db/services/test_dialog_service_use_sql_source_columns.py @@ -295,6 +295,7 @@ def test_async_chat_uses_all_docs_when_no_doc_ids_selected(monkeypatch): dialog = SimpleNamespace( kb_ids=["kb-1"], llm_id="chat-model", + tenant_llm_id="", tenant_id="tenant-id", llm_setting={}, similarity_threshold=0.1, diff --git a/tools/scripts/mysql_migration.py b/tools/scripts/mysql_migration.py index 5b510efa70..50f255332b 100644 --- a/tools/scripts/mysql_migration.py +++ b/tools/scripts/mysql_migration.py @@ -156,6 +156,16 @@ class MigrationDatabase: cursor = self.execute_sql("SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = %s AND table_name = %s AND column_name = %s", (self.config.database, table_name, column_name)) return cursor.fetchone()[0] > 0 + def get_column_type(self, table_name: str, column_name: str) -> str | None: + """Get the DATA_TYPE of a column from information_schema, returns None if column does not exist""" + cursor = self.execute_sql( + "SELECT DATA_TYPE FROM information_schema.columns " + "WHERE table_schema = %s AND table_name = %s AND column_name = %s", + (self.config.database, table_name, column_name) + ) + row = cursor.fetchone() + return row[0] if row else None + def get_system_setting_value(self, name: str) -> str | None: if not self.table_exists("system_settings"): logger.info("Table 'system_settings' does not exist, migration marker is unavailable") @@ -1227,12 +1237,792 @@ class ModelIdConfigStage(MigrationStage): return rows_updated, sorted(tables_operated) +class TenantModelSeedingStage(MigrationStage): + """Seed tenant_model table with models from conf/llm_factories.json""" + + name = "tenant_model_seeding" + description = "Seed tenant_model with models from conf/llm_factories.json for existing providers/instances" + source_tables = ["tenant_model_provider", "tenant_model_instance", "tenant_model"] + target_tables = ["tenant_model"] + + def current_timestamp(self) -> int: + return int(time.time()) + + def generate_uuid(self) -> str: + """Generate 32-character UUID1""" + return uuid.uuid1().hex + + def _load_llm_factories(self) -> list: + """Load factory_llm_infos from conf/llm_factories.json""" + conf_path = os.path.join(PROJECT_BASE, "conf", "llm_factories.json") + with open(conf_path, "r") as f: + data = json.load(f) + return data.get("factory_llm_infos", []) + + def check(self) -> bool: + """Check if migration is needed""" + if not self.db.table_exists("tenant_model_provider"): + logger.warning("Dependency table 'tenant_model_provider' does not exist") + return False + + if not self.db.table_exists("tenant_model_instance"): + logger.warning("Dependency table 'tenant_model_instance' does not exist") + return False + + if not self.db.table_exists("tenant_model"): + if self.dry_run: + logger.info("[DRY RUN] Target table 'tenant_model' does not exist") + return False + return True + + # If model_type is already INT, the merge stage has been executed — seeding is not applicable + model_type_dtype = self.db.get_column_type("tenant_model", "model_type") + if model_type_dtype and model_type_dtype.lower() == "int": + self.mark_noop_completes_migration() + logger.info("tenant_model.model_type is already INT, seeding stage is not applicable (merge already done)") + return False + + # Check if there are any models to seed + factories = self._load_llm_factories() + total_missing = 0 + for factory in factories: + llm_list = factory.get("llm", []) + if not llm_list: + continue + factory_name = factory["name"] + # Determine the provider_name to query and optional instance extra filter + if factory_name == "siliconflow_intl": + provider_name_filter = "SILICONFLOW" + instance_extra_include = '%"region": "intl"%' + instance_extra_exclude = None + elif factory_name == "SILICONFLOW": + provider_name_filter = "SILICONFLOW" + instance_extra_include = None + instance_extra_exclude = '%"region": "intl"%' + else: + provider_name_filter = factory_name + instance_extra_include = None + instance_extra_exclude = None + # Query provider for this factory + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_provider WHERE provider_name = %s", + (provider_name_filter,) + ) + providers = cursor.fetchall() + for provider_id, in providers: + # Query instances for this provider, with optional extra include/exclude filter + if instance_extra_include and instance_extra_exclude: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s AND extra LIKE %s AND extra NOT LIKE %s", + (provider_id, instance_extra_include, instance_extra_exclude) + ) + elif instance_extra_include: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s AND extra LIKE %s", + (provider_id, instance_extra_include) + ) + elif instance_extra_exclude: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s AND extra NOT LIKE %s", + (provider_id, instance_extra_exclude) + ) + else: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s", + (provider_id,) + ) + instances = cursor.fetchall() + for instance_id, in instances: + for llm in llm_list: + model_name = llm.get("llm_name", "") + model_types = llm.get("model_type", "chat") + if isinstance(model_types, str): + model_types = [model_types] + for mt in model_types: + cursor = self.db.execute_sql( + "SELECT COUNT(*) FROM tenant_model " + "WHERE provider_id = %s AND instance_id = %s AND model_name = %s AND model_type = %s", + (provider_id, instance_id, model_name, mt) + ) + if cursor.fetchone()[0] == 0: + total_missing += 1 + + if total_missing == 0: + self.mark_noop_completes_migration() + logger.info("No missing models to seed in tenant_model") + return False + + logger.info(f"Found {total_missing} missing models to seed in tenant_model") + return True + + def execute(self) -> tuple[int, list]: + """Execute migration""" + current_ts = self.current_timestamp() + rows_inserted = 0 + + if not self.db.table_exists("tenant_model_provider"): + logger.error("Dependency table 'tenant_model_provider' does not exist") + return 0, [] + + if not self.db.table_exists("tenant_model_instance"): + logger.error("Dependency table 'tenant_model_instance' does not exist") + return 0, [] + + if not self.db.table_exists("tenant_model"): + if self.dry_run: + logger.info("[DRY RUN] Target table 'tenant_model' does not exist") + return 0, [] + logger.info("Target table 'tenant_model' does not exist, will create") + self.create_target_table() + + if self.create_table_only: + logger.info("[CREATE TABLE ONLY] Target table created/verified, skipping data seeding") + return 0, self.target_tables + + # Pre-load existing extra values for model_names from tenant_model + # to reuse non-default extra for same model_name across providers + cursor = self.db.execute_sql( + "SELECT model_name, extra FROM tenant_model WHERE extra != '{}' AND extra IS NOT NULL" + ) + existing_extra_map = {} + for model_name, extra in cursor.fetchall(): + if model_name and extra and extra != "{}": + existing_extra_map[model_name] = extra + + # Pre-load existing status values for model_names from tenant_model + # Prefer non-unsupported status when seeding new records for the same model_name + cursor = self.db.execute_sql( + "SELECT model_name, status FROM tenant_model WHERE status != 'unsupported' AND status IS NOT NULL" + ) + existing_status_map = {} + for model_name, status in cursor.fetchall(): + if model_name and status: + existing_status_map[model_name] = status + + factories = self._load_llm_factories() + all_records = [] + + for factory in factories: + llm_list = factory.get("llm", []) + if not llm_list: + continue + factory_name = factory["name"] + + # Determine the provider_name to query and optional instance extra filter + if factory_name == "siliconflow_intl": + provider_name_filter = "SILICONFLOW" + instance_extra_include = '%"region": "intl"%' + instance_extra_exclude = None + elif factory_name == "SILICONFLOW": + provider_name_filter = "SILICONFLOW" + instance_extra_include = None + instance_extra_exclude = '%"region": "intl"%' + else: + provider_name_filter = factory_name + instance_extra_include = None + instance_extra_exclude = None + + # Query provider for this factory + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_provider WHERE provider_name = %s", + (provider_name_filter,) + ) + providers = cursor.fetchall() + + for provider_id, in providers: + # Query instances for this provider, with optional extra include/exclude filter + if instance_extra_include and instance_extra_exclude: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s AND extra LIKE %s AND extra NOT LIKE %s", + (provider_id, instance_extra_include, instance_extra_exclude) + ) + elif instance_extra_include: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s AND extra LIKE %s", + (provider_id, instance_extra_include) + ) + elif instance_extra_exclude: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s AND extra NOT LIKE %s", + (provider_id, instance_extra_exclude) + ) + else: + cursor = self.db.execute_sql( + "SELECT id FROM tenant_model_instance WHERE provider_id = %s", + (provider_id,) + ) + instances = cursor.fetchall() + if not instances: + logger.warning(f"No instances found for provider '{provider_name_filter}' (id={provider_id}), skipping") + continue + + for instance_id, in instances: + for llm in llm_list: + model_name = llm.get("llm_name", "") + if not model_name: + continue + model_types = llm.get("model_type", "chat") + if isinstance(model_types, str): + model_types = [model_types] + + # Determine extra: reuse existing non-default extra for same model_name; + # otherwise build default from is_tools and max_tokens in the llm entry + if model_name in existing_extra_map: + extra = existing_extra_map[model_name] + else: + extra_dict = {} + if llm.get("is_tools") is True: + extra_dict["is_tools"] = True + max_tokens = llm.get("max_tokens") + if max_tokens is not None: + extra_dict["max_tokens"] = max_tokens + extra = json.dumps(extra_dict) if extra_dict else "{}" + + # Determine status: reuse existing non-unsupported status for same model_name, else "active" + status = existing_status_map.get(model_name, "active") + + for mt in model_types: + # Check if record already exists + cursor = self.db.execute_sql( + "SELECT COUNT(*) FROM tenant_model " + "WHERE provider_id = %s AND instance_id = %s AND model_name = %s AND model_type = %s", + (provider_id, instance_id, model_name, mt) + ) + if cursor.fetchone()[0] > 0: + continue + all_records.append((model_name, provider_id, instance_id, mt, status, extra)) + + if not all_records: + logger.info("No missing models to seed") + return 0, [] + + logger.info(f"Seeding {len(all_records)} tenant_model records...") + + if self.dry_run: + logger.info(f"[DRY RUN] Would insert {len(all_records)} records") + for model_name, provider_id, instance_id, mt, status, extra in all_records[:5]: + logger.info(f" model_name={model_name}, provider_id={provider_id}, " + f"instance_id={instance_id}, model_type={mt}, status={status}, extra={extra}") + if len(all_records) > 5: + logger.info(f" ... and {len(all_records) - 5} more records") + return len(all_records), self.target_tables + + # Insert records in batches + batch_size = 100 + for i in range(0, len(all_records), batch_size): + batch = all_records[i:i + batch_size] + values = [] + for model_name, provider_id, instance_id, mt, status, extra in batch: + record_id = self.generate_uuid() + model_name_escaped = model_name.replace("'", "''") if model_name else "" + mt_escaped = mt.replace("'", "''") if mt else "" + extra_escaped = extra.replace("'", "''") if extra else "{}" + status_escaped = status.replace("'", "''") if status else "active" + values.append(f"('{record_id}', '{model_name_escaped}', '{provider_id}', " + f"'{instance_id}', '{mt_escaped}', '{status_escaped}', " + f"'{extra_escaped}', " + f"{current_ts * 1000}, FROM_UNIXTIME({current_ts}), " + f"{current_ts * 1000}, FROM_UNIXTIME({current_ts}))") + + insert_sql = f""" + INSERT INTO tenant_model + (id, model_name, provider_id, instance_id, model_type, status, extra, + create_time, create_date, update_time, update_date) + VALUES {', '.join(values)} + """ + self.db.execute_sql(insert_sql) + rows_inserted += len(batch) + logger.info(f"Inserted batch {i // batch_size + 1}: {len(batch)} records") + + return rows_inserted, self.target_tables + + def create_target_table(self): + """Create tenant_model table""" + create_sql = """ + CREATE TABLE IF NOT EXISTS tenant_model ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + model_name VARCHAR(128), + provider_id VARCHAR(32) NOT NULL, + instance_id VARCHAR(32) NOT NULL, + model_type VARCHAR(32) NOT NULL, + status VARCHAR(32) DEFAULT 'active', + extra VARCHAR(1024) DEFAULT '{}', + create_time BIGINT, + create_date DATETIME, + update_time BIGINT, + update_date DATETIME, + INDEX idx_instance_id (instance_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ + self.db.execute_sql(create_sql) + logger.info("Created tenant_model table") + + +class ModelTypeMergeStage(MigrationStage): + """Merge tenant_model rows by (provider_id, instance_id, model_name), converting model_type to binary integer""" + + name = "model_type_merge" + description = "Merge tenant_model rows by provider_id/instance_id/model_name, converting model_type to binary integer" + source_tables = ["tenant_model"] + target_tables = ["tenant_model_merge_tmp"] + + # Mapping from LLMType string values to ModelTypeBinary integer values + MODEL_TYPE_STR_TO_INT = { + "chat": 1, # 0b0000001 + "embedding": 2, # 0b0000010 + "speech2text": 4, # 0b0000100 + "image2text": 8, # 0b0001000 + "rerank": 16, # 0b0010000 + "tts": 32, # 0b0100000 + "ocr": 64, # 0b1000000 + } + + def current_timestamp(self) -> int: + return int(time.time()) + + def generate_uuid(self) -> str: + """Generate 32-character UUID1""" + return uuid.uuid1().hex + + def check(self) -> bool: + """Check if migration is needed""" + if not self.db.table_exists("tenant_model"): + logger.warning("Source table 'tenant_model' does not exist") + return False + + # If model_type is already INT, the merge has already been executed — no work needed + model_type_dtype = self.db.get_column_type("tenant_model", "model_type") + if model_type_dtype and model_type_dtype.lower() == "int": + self.mark_noop_completes_migration() + logger.info("tenant_model.model_type is already INT, merge stage is not applicable (already done)") + return False + + return True + + def execute(self) -> tuple[int, list]: + """Execute migration""" + current_ts = self.current_timestamp() + rows_inserted = 0 + + if not self.db.table_exists("tenant_model"): + logger.error("Source table 'tenant_model' does not exist") + return 0, [] + + # Create temporary table with model_type as INTEGER + self.create_target_table() + + if self.create_table_only: + logger.info("[CREATE TABLE ONLY] Temporary table created, skipping data merge") + return 0, self.target_tables + + # Load all records from tenant_model + cursor = self.db.execute_sql( + "SELECT id, model_name, provider_id, instance_id, model_type, status, extra, " + "create_time, create_date, update_time, update_date " + "FROM tenant_model ORDER BY id" + ) + all_rows = cursor.fetchall() + + # Group by (provider_id, instance_id, model_name) + from collections import defaultdict + groups = defaultdict(list) + for row in all_rows: + id_, model_name, provider_id, instance_id, model_type_str, status, extra, \ + create_time, create_date, update_time, update_date = row + groups[(provider_id, instance_id, model_name)].append(row) + + # Merge each group into a single record + merged_records = [] + for (provider_id, instance_id, model_name), rows in groups.items(): + # Compute binary model_type: + # OR all model_types where status != 'unsupported' + # Then AND with complement of OR of all model_types where status == 'unsupported' + supported_bits = 0 + unsupported_bits = 0 + first_non_unsupported_status = None + first_row = rows[0] + + for row in rows: + _, _, _, _, model_type_str, status, _, _, _, _, _ = row + type_bit = self.MODEL_TYPE_STR_TO_INT.get(model_type_str, 0) + if status == "unsupported": + unsupported_bits |= type_bit + else: + supported_bits |= type_bit + if first_non_unsupported_status is None: + first_non_unsupported_status = status + + merged_type = supported_bits & ~unsupported_bits + merged_status = first_non_unsupported_status or "active" + + # Use first row's values for all other fields, but replace id, model_type, status + id_, model_name, provider_id, instance_id, _, _, extra, \ + create_time, create_date, update_time, update_date = first_row + + # Only include records whose merged status is "active" + if merged_status != "active": + continue + + merged_records.append(( + self.generate_uuid(), model_name, provider_id, instance_id, + merged_type, merged_status, extra, + create_time, create_date, update_time, update_date + )) + + if not merged_records: + logger.info("No records to merge") + return 0, [] + + logger.info(f"Merging {len(all_rows)} rows into {len(merged_records)} rows...") + + if self.dry_run: + logger.info(f"[DRY RUN] Would insert {len(merged_records)} merged rows, " + f"then rename tables") + for rec in merged_records[:5]: + _, model_name, provider_id, instance_id, merged_type, status, extra, *_ = rec + logger.info(f" model_name={model_name}, provider_id={provider_id}, " + f"instance_id={instance_id}, model_type={merged_type}, " + f"status={status}, extra={extra}") + if len(merged_records) > 5: + logger.info(f" ... and {len(merged_records) - 5} more rows") + return len(merged_records), self.target_tables + + # Insert merged records into temporary table + batch_size = 100 + for i in range(0, len(merged_records), batch_size): + batch = merged_records[i:i + batch_size] + values = [] + for rec in batch: + id_, model_name, provider_id, instance_id, merged_type, status, extra, \ + create_time, create_date, update_time, update_date = rec + model_name_escaped = (model_name.replace("'", "''") if model_name else "") or None + extra_escaped = extra.replace("'", "''") if extra else "{}" + status_escaped = status.replace("'", "''") if status else "active" + # Handle NULL date fields + create_time_val = create_time if create_time is not None else current_ts * 1000 + update_time_val = update_time if update_time is not None else current_ts * 1000 + create_date_sql = f"FROM_UNIXTIME({int(create_time_val / 1000)})" if create_time_val else "NULL" + update_date_sql = f"FROM_UNIXTIME({int(update_time_val / 1000)})" if update_time_val else "NULL" + values.append(f"('{id_}', '{model_name_escaped}', '{provider_id}', " + f"'{instance_id}', {merged_type}, '{status_escaped}', " + f"'{extra_escaped}', " + f"{create_time_val}, {create_date_sql}, " + f"{update_time_val}, {update_date_sql})") + + insert_sql = f""" + INSERT INTO tenant_model_merge_tmp + (id, model_name, provider_id, instance_id, model_type, status, extra, + create_time, create_date, update_time, update_date) + VALUES {', '.join(values)} + """ + self.db.execute_sql(insert_sql) + rows_inserted += len(batch) + logger.info(f"Inserted batch {i // batch_size + 1}: {len(batch)} merged rows") + + # Rename original table to backup, then rename temp table to tenant_model + logger.info("Renaming tables: tenant_model -> tenant_model_backup, tenant_model_merge_tmp -> tenant_model") + self.db.execute_sql("DROP TABLE IF EXISTS tenant_model_backup") + self.db.execute_sql("ALTER TABLE tenant_model RENAME TO tenant_model_backup") + self.db.execute_sql("ALTER TABLE tenant_model_merge_tmp RENAME TO tenant_model") + logger.info("Table rename completed successfully") + + return rows_inserted, ["tenant_model_merge_tmp", "tenant_model", "tenant_model_backup"] + + def create_target_table(self): + """Create temporary table tenant_model_merge_tmp with model_type as INTEGER""" + create_sql = """ + CREATE TABLE IF NOT EXISTS tenant_model_merge_tmp ( + id VARCHAR(32) NOT NULL PRIMARY KEY, + model_name VARCHAR(128), + provider_id VARCHAR(32) NOT NULL, + instance_id VARCHAR(32) NOT NULL, + model_type INT NOT NULL, + status VARCHAR(32) DEFAULT 'active', + extra VARCHAR(1024) DEFAULT '{}', + create_time BIGINT, + create_date DATETIME, + update_time BIGINT, + update_date DATETIME, + INDEX idx_instance_id (instance_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ + self.db.execute_sql(create_sql) + logger.info("Created tenant_model_merge_tmp table") + + +class TenantModelIdMigrationStage(MigrationStage): + """Populate tenant_*_id columns in tenant, knowledgebase, dialog, memory with tenant_model.id values.""" + + name = "tenant_model_id_migration" + description = "Populate tenant_*_id columns with tenant_model.id, converting from IntegerField to CharField (VARCHAR(32))" + source_tables = ["tenant_model", "tenant_model_provider", "tenant_model_instance", "tenant", "knowledgebase", "dialog", "memory"] + target_tables = ["tenant", "knowledgebase", "dialog", "memory"] + + # Maps table -> list of (tenant_*_id column, legacy *_id column, model_type for lookup) + # The legacy column holds the model_name (e.g. "gpt-4o@default@OpenAI"), we use it to find tenant_model.id + TENANT_ID_FIELDS = { + "tenant": [ + ("tenant_llm_id", "llm_id", "chat"), + ("tenant_embd_id", "embd_id", "embedding"), + ("tenant_asr_id", "asr_id", "speech2text"), + ("tenant_img2txt_id", "img2txt_id", "image2text"), + ("tenant_rerank_id", "rerank_id", "rerank"), + ("tenant_tts_id", "tts_id", "tts"), + ("tenant_ocr_id", "ocr_id", "ocr"), + ], + "knowledgebase": [ + ("tenant_embd_id", "embd_id", "embedding"), + ], + "dialog": [ + ("tenant_llm_id", "llm_id", "chat"), + ("tenant_rerank_id", "rerank_id", "rerank"), + ], + "memory": [ + ("tenant_embd_id", "embd_id", "embedding"), + ("tenant_llm_id", "llm_id", "chat"), + ], + } + + # model_type string -> binary integer mapping + MODEL_TYPE_TO_INT = { + "chat": 1, + "embedding": 2, + "speech2text": 4, + "image2text": 8, + "rerank": 16, + "tts": 32, + "ocr": 64, + } + + scan_batch_size = 500 + + def check(self) -> bool: + """Check if migration is needed - any tenant_*_id column is still INT or empty.""" + if not self.db.table_exists("tenant_model"): + logger.warning("Dependency table 'tenant_model' does not exist") + return False + + if not self.db.table_exists("tenant_model_provider"): + logger.warning("Dependency table 'tenant_model_provider' does not exist") + return False + + if not self.db.table_exists("tenant_model_instance"): + logger.warning("Dependency table 'tenant_model_instance' does not exist") + return False + + has_work = False + for table_name, fields in self.TENANT_ID_FIELDS.items(): + if not self.db.table_exists(table_name): + continue + for tenant_id_col, _, _ in fields: + if not self.db.column_exists(table_name, tenant_id_col): + # Column does not exist yet — needs to be added + has_work = True + break + # Check if column is still INT type -> needs conversion + col_type = self.db.get_column_type(table_name, tenant_id_col) + if col_type and col_type.lower() in ("int", "bigint", "integer"): + has_work = True + break + # Check if there are NULL values that should be populated + cursor = self.db.execute_sql( + f"SELECT COUNT(*) FROM `{table_name}` WHERE `{tenant_id_col}` IS NULL OR `{tenant_id_col}` = ''" + ) + null_count = cursor.fetchone()[0] + if null_count > 0: + has_work = True + break + if has_work: + break + + if not has_work: + self.mark_noop_completes_migration() + logger.info("All tenant_*_id columns are already VARCHAR(32) and populated, no migration needed") + return False + + return True + + def execute(self) -> tuple[int, list]: + """Execute migration: alter column types and populate tenant_*_id values.""" + rows_updated = 0 + tables_operated = set() + + if self.create_table_only: + logger.info("[CREATE TABLE ONLY] No tables are created for this data migration") + return 0, [] + + # Step 1: Add missing columns as VARCHAR(32) or alter existing INT columns to VARCHAR(32) + for table_name, fields in self.TENANT_ID_FIELDS.items(): + if not self.db.table_exists(table_name): + continue + for tenant_id_col, _, _ in fields: + if not self.db.column_exists(table_name, tenant_id_col): + # Column does not exist yet — add it as VARCHAR(32) + logger.info(f"Adding column {table_name}.{tenant_id_col} as VARCHAR(32) NULL") + if not self.dry_run: + self.db.execute_sql( + f"ALTER TABLE `{table_name}` ADD COLUMN `{tenant_id_col}` VARCHAR(32) NULL" + ) + tables_operated.add(table_name) + continue + col_type = self.db.get_column_type(table_name, tenant_id_col) + if col_type and col_type.lower() in ("int", "bigint", "integer"): + logger.info(f"Converting {table_name}.{tenant_id_col} from {col_type} to VARCHAR(32)") + if not self.dry_run: + self.db.execute_sql( + f"ALTER TABLE `{table_name}` MODIFY COLUMN `{tenant_id_col}` VARCHAR(32) NULL" + ) + tables_operated.add(table_name) + + # Step 2: Build lookup cache from tenant_model joined with provider + instance + # to resolve model_name@instance@provider -> tenant_model.id + model_lookup = self._build_model_lookup() + logger.info(f"Built model lookup with {len(model_lookup)} entries") + + # Step 3: Populate tenant_*_id from model_name + for table_name, fields in self.TENANT_ID_FIELDS.items(): + if not self.db.table_exists(table_name): + continue + for tenant_id_col, legacy_col, model_type_str in fields: + if not self.db.column_exists(table_name, tenant_id_col): + logger.info(f"Column {table_name}.{tenant_id_col} does not exist, skipping") + continue + if not self.db.column_exists(table_name, legacy_col): + logger.info(f"Column {table_name}.{legacy_col} does not exist, skipping") + continue + + # Get rows where tenant_*_id is NULL or empty + cursor = self.db.execute_sql( + f"SELECT id, `{legacy_col}` FROM `{table_name}` " + f"WHERE (`{tenant_id_col}` IS NULL OR `{tenant_id_col}` = '') " + f"AND `{legacy_col}` IS NOT NULL AND `{legacy_col}` != ''" + ) + + # Also need tenant_id for model lookup + # For tenant table, the PK is the tenant_id + # For knowledgebase, dialog, memory we also need tenant_id + if table_name == "tenant": + cursor = self.db.execute_sql( + f"SELECT id, `{legacy_col}` FROM `{table_name}` " + f"WHERE (`{tenant_id_col}` IS NULL OR `{tenant_id_col}` = '') " + f"AND `{legacy_col}` IS NOT NULL AND `{legacy_col}` != ''" + ) + while True: + rows = cursor.fetchmany(self.scan_batch_size) + if not rows: + break + for row_id, model_name in rows: + tenant_id = row_id # For tenant table, id == tenant_id + resolved_id = self._resolve_model_id(model_lookup, tenant_id, model_name, model_type_str) + if resolved_id: + if not self.dry_run: + self.db.execute_sql( + f"UPDATE `{table_name}` SET `{tenant_id_col}` = %s WHERE id = %s", + (resolved_id, row_id), + ) + else: + if not self.dry_run: + self.db.execute_sql( + f"UPDATE `{table_name}` SET `{tenant_id_col}` = '' WHERE id = %s", + (row_id,), + ) + rows_updated += 1 + tables_operated.add(table_name) + else: + cursor = self.db.execute_sql( + f"SELECT id, tenant_id, `{legacy_col}` FROM `{table_name}` " + f"WHERE (`{tenant_id_col}` IS NULL OR `{tenant_id_col}` = '') " + f"AND `{legacy_col}` IS NOT NULL AND `{legacy_col}` != ''" + ) + while True: + rows = cursor.fetchmany(self.scan_batch_size) + if not rows: + break + for row_id, tenant_id, model_name in rows: + resolved_id = self._resolve_model_id(model_lookup, tenant_id, model_name, model_type_str) + if resolved_id: + if not self.dry_run: + self.db.execute_sql( + f"UPDATE `{table_name}` SET `{tenant_id_col}` = %s WHERE id = %s", + (resolved_id, row_id), + ) + else: + if not self.dry_run: + self.db.execute_sql( + f"UPDATE `{table_name}` SET `{tenant_id_col}` = '' WHERE id = %s", + (row_id,), + ) + rows_updated += 1 + tables_operated.add(table_name) + + if self.dry_run: + logger.info(f"[DRY RUN] Would update {rows_updated} rows") + else: + logger.info(f"Updated {rows_updated} rows with tenant_model.id values") + + return rows_updated, sorted(tables_operated) + + def _split_model_name(self, model_name: str) -> tuple[str, str, str]: + """Parse model_name: {model_name}@{factory_name} or {model_name}@{instance_name}@{factory_name}""" + if not model_name: + return "", "", "" + parts = model_name.split("@") + if len(parts) == 1: + return parts[0], "default", "" + elif len(parts) == 2: + return parts[0], "default", parts[1] + else: + return parts[0], parts[1], parts[2] + + def _build_model_lookup(self) -> dict: + """Build a lookup dict: (tenant_id, model_name_pure, provider_name, model_type_int) -> tenant_model.id""" + cursor = self.db.execute_sql( + "SELECT tm.id, tm.model_name, tm.model_type, tmp.tenant_id, tmp.provider_name " + "FROM tenant_model tm " + "INNER JOIN tenant_model_provider tmp ON tm.provider_id = tmp.id " + "WHERE tm.status = 'active'" + ) + lookup = {} + for model_id, model_name, model_type, tenant_id, provider_name in cursor.fetchall(): + # model_type is a binary integer; we check each bit + for type_str, type_bit in self.MODEL_TYPE_TO_INT.items(): + if model_type & type_bit: + key = (tenant_id, model_name, provider_name, type_str) + lookup[key] = model_id + return lookup + + def _resolve_model_id(self, model_lookup: dict, tenant_id: str, model_name: str, model_type_str: str) -> str | None: + """Look up tenant_model.id from the model_lookup cache.""" + pure_name, instance_name, provider_name = self._split_model_name(model_name) + if not pure_name or not provider_name: + logger.warning(f"Cannot resolve model id for {model_name}: missing model_name or provider") + return None + + # Try exact lookup with the provider name from the model_name + key = (tenant_id, pure_name, provider_name, model_type_str) + result = model_lookup.get(key) + if result: + return result + + # Try with instance_name as part of provider (shouldn't normally happen) + # Try without specific provider (any provider matching model_name + type for this tenant) + for (t_id, m_name, p_name, m_type), m_id in model_lookup.items(): + if t_id == tenant_id and m_name == pure_name and m_type == model_type_str: + return m_id + + logger.warning(f"No tenant_model.id found for tenant={tenant_id}, model={model_name}, type={model_type_str}") + return None + + # Registry of available migration stages MIGRATION_STAGES = { "tenant_model_provider": TenantModelProviderStage, "tenant_model_instance": TenantModelInstanceStage, "tenant_model": TenantModelStage, "model_id_config": ModelIdConfigStage, + "tenant_model_seeding": TenantModelSeedingStage, + "model_type_merge": ModelTypeMergeStage, + "tenant_model_id_migration": TenantModelIdMigrationStage, } diff --git a/tools/scripts/run_migrations.sh b/tools/scripts/run_migrations.sh new file mode 100755 index 0000000000..e2492e077b --- /dev/null +++ b/tools/scripts/run_migrations.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# ----------------------------------------------------------------------------- +# Shared migration script for model provider tables. +# +# Called by docker/entrypoint.sh and docker/launch_backend_service.sh. +# Keeps migration stages and versions in one place to avoid divergence. +# +# Usage: +# PY=python3 tools/scripts/run_migrations.sh [--config CONFIG_PATH] +# +# Environment variables: +# PY - Python interpreter path (default: python3) +# ----------------------------------------------------------------------------- + +set -e + +PY="${PY:-python3}" +CONFIG="${1:-conf/service_conf.yaml}" + +echo "Running model provider table migrations..." + +# Step 1: Create base model provider tables +"$PY" tools/scripts/mysql_migration.py \ + --stages tenant_model_provider,tenant_model_instance,tenant_model,model_id_config \ + --config "$CONFIG" \ + --execute \ + --database-version "v0.26.0" \ + --mark-database-version-on-success + +# Step 2: Seed, merge model types, and migrate model IDs +"$PY" tools/scripts/mysql_migration.py \ + --stages tenant_model_seeding,model_type_merge,tenant_model_id_migration \ + --config "$CONFIG" \ + --execute \ + --database-version "v0.27.0.dev0" \ + --mark-database-version-on-success + +echo "Model provider table migrations completed." diff --git a/web/src/hooks/use-llm-request.tsx b/web/src/hooks/use-llm-request.tsx index 73370d625b..ebc8a28b0a 100644 --- a/web/src/hooks/use-llm-request.tsx +++ b/web/src/hooks/use-llm-request.tsx @@ -12,14 +12,17 @@ import { IAddInstanceModelRequestBody, IAddProviderInstanceRequestBody, IAddProviderRequestBody, + IDeleteInstanceModelsRequestBody, IDeleteProviderInstanceRequestBody, IEditInstanceModelRequestBody, IListAllModelsRequestParams, IListProviderModelsRequestBody, IListProvidersRequestParams, IModelInfo, + IPatchInstanceModelRequestBody, ISetDefaultModelRequestBody, IUpdateModelStatusRequestBody, + IUpdateProviderInstanceRequestBody, } from '@/interfaces/request/llm'; import llmService from '@/services/llm-service'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -40,6 +43,9 @@ export const enum LLMApiAction { AddInstanceModel = 'addInstanceModel', EditInstanceModel = 'editInstanceModel', DeleteProviderInstance = 'deleteProviderInstance', + DeleteInstanceModels = 'deleteInstanceModels', + UpdateProviderInstance = 'updateProviderInstance', + PatchInstanceModel = 'patchInstanceModel', ListDefaultModels = 'listDefaultModels', SetDefaultModel = 'setDefaultModel', } @@ -153,12 +159,6 @@ export const useFetchProviderInstances = (providerName: string) => { return { data, loading }; }; -/** - * Fetch full details of a single provider instance (used in viewMode to - * retrieve fields like `baseUrl` that the list endpoint does not return). - * Disabled by default; call from an event handler (e.g. onClick) and - * rely on the returned `refetch` to actually trigger the request. - */ export const useFetchProviderInstance = ( providerName: string, instanceName: string, @@ -186,7 +186,7 @@ export const useFetchInstanceModels = ( queryKey: LlmKeys.instanceModels(providerName, instanceName), initialData: [], gcTime: 0, - enabled: !!providerName && !!instanceName, + enabled: !!providerName && !!instanceName && instanceName !== '__draft__', queryFn: async () => { const { data } = await llmService.listInstanceModels( { provider_name: providerName, instance_name: instanceName }, @@ -246,27 +246,37 @@ export const useAddProviderInstance = () => { try { await addProvider({ provider_name: params.llm_factory }); - const { data: instancesRes } = await llmService.listProviderInstances( - { provider_name: params.llm_factory }, - true, - ); - const instanceExists = instancesRes?.data?.some( - (i: IProviderInstance) => i.instance_name === params.instance_name, - ); - if (instanceExists && !params.verify) { - return { code: 0, data: null }; + // When `id` is supplied the caller is updating an existing + // instance (blur-save on a saved card), so do not short-circuit + // on the "already exists" check — we *want* the server call. + if (!params.id) { + const { data: instancesRes } = await llmService.listProviderInstances( + { provider_name: params.llm_factory }, + true, + ); + const instanceExists = instancesRes?.data?.some( + (i: IProviderInstance) => i.instance_name === params.instance_name, + ); + if (instanceExists && !params.verify) { + return { code: 0, data: null }; + } } } catch { // ignore list failure and proceed to add } - const { data } = await llmService.addProviderInstance(params); + // The provider is carried in the URL path + // (`/providers//instances`), so `llm_factory` must not + // be duplicated in the request body. Keep it only for URL building + // (native-config form) and send the remaining fields as the body. + const { llm_factory, ...body } = params; + const { data } = await llmService.addProviderInstance( + { llm_factory, data: body }, + true, + ); if (data.code === 0 && !params.verify) { queryClient.invalidateQueries({ - queryKey: LlmKeys.addedProviders(), - }); - queryClient.invalidateQueries({ - queryKey: LlmKeys.allModels(), + queryKey: LlmKeys.providerInstances(params.llm_factory), }); } return data; @@ -342,12 +352,23 @@ export const useAddInstanceModel = () => { ) => { const { data } = await llmService.addInstanceModel(params); if (data.code === 0) { + // `exact: true` keeps the invalidation to the provider summary + // list. Without it the [AddedProviders] prefix would also match + // every providerInstances / instanceModels query and refetch + // every provider's instances — we only want the current one. queryClient.invalidateQueries({ queryKey: LlmKeys.addedProviders(), + exact: true, }); queryClient.invalidateQueries({ queryKey: LlmKeys.allModels(), }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.instanceModels( + params.provider_name, + params.instance_name, + ), + }); } return data; }, @@ -455,6 +476,119 @@ export const useUpdateModelStatus = () => { return { loading, updateModelStatus: mutateAsync }; }; +/** + * PATCH `/providers/{name}/instances/{name}/models/{model_name}` — updates + * a single model's editable fields (max_tokens, model_type, status, is_tools). + * Used by the per-row Edit dialog. Distinct from `useUpdateModelStatus` + * (which only flips the active/inactive bit) so call sites can pass the + * full set of editable fields without coercing them into the status hook. + */ +export const usePatchInstanceModel = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { isPending: loading, mutateAsync } = useMutation({ + mutationKey: [LLMApiAction.PatchInstanceModel], + mutationFn: async (params: IPatchInstanceModelRequestBody) => { + const { data } = await llmService.patchInstanceModel(params); + if (data.code === 0) { + message.success(t('message.modified')); + queryClient.invalidateQueries({ + queryKey: LlmKeys.addedProviders(), + exact: true, + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.instanceModels( + params.provider_name, + params.instance_name, + ), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.allModels(), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.defaultModels(), + }); + } + return data; + }, + }); + + return { loading, patchInstanceModel: mutateAsync }; +}; + +export const useDeleteInstanceModels = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { isPending: loading, mutateAsync } = useMutation({ + mutationKey: [LLMApiAction.DeleteInstanceModels], + mutationFn: async (params: IDeleteInstanceModelsRequestBody) => { + const { data } = await llmService.deleteInstanceModels(params); + if (data.code === 0) { + message.success(t('message.deleted')); + queryClient.invalidateQueries({ + queryKey: LlmKeys.addedProviders(), + exact: true, + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.allModels(), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.defaultModels(), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.instanceModels( + params.provider_name, + params.instance_name, + ), + }); + } + return data; + }, + }); + + return { loading, deleteInstanceModels: mutateAsync }; +}; + +export const useUpdateProviderInstance = () => { + const queryClient = useQueryClient(); + const { isPending: loading, mutateAsync } = useMutation({ + mutationKey: [LLMApiAction.UpdateProviderInstance], + mutationFn: async (params: IUpdateProviderInstanceRequestBody) => { + const { data } = await llmService.updateProviderInstance(params); + if (data.code === 0) { + queryClient.invalidateQueries({ + queryKey: LlmKeys.addedProviders(), + exact: true, + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.providerInstances(params.provider_name), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.providerInstance( + params.provider_name, + params.instance_name, + ), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.instanceModels( + params.provider_name, + params.instance_name, + ), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.allModels(), + }); + queryClient.invalidateQueries({ + queryKey: LlmKeys.defaultModels(), + }); + } + return data; + }, + }); + + return { loading, updateProviderInstance: mutateAsync }; +}; + export const useFetchDefaultModels = () => { const { data, isFetching: loading } = useQuery({ queryKey: LlmKeys.defaultModels(), diff --git a/web/src/interfaces/database/llm.ts b/web/src/interfaces/database/llm.ts index ae4119e0be..35c347865c 100644 --- a/web/src/interfaces/database/llm.ts +++ b/web/src/interfaces/database/llm.ts @@ -45,10 +45,16 @@ export interface IAvailableProvider { name: string; model_types: string[]; url: { default?: string; [key: string]: string | undefined }; + has_instance: boolean; } export interface IProviderInstance { - api_key: string; + /** + * Usually a plain key string, but the showProviderInstance endpoint + * may return an object `{ api_key, ...nested }` for providers that + * bundle extra credentials (see the nested fields below). + */ + api_key: string | Record; id: string; instance_name: string; provider_id: string; @@ -56,10 +62,20 @@ export interface IProviderInstance { status: string; /** * Optional: only returned by the showProviderInstance endpoint. Used - * to pre-fill the base_url/api_base form field in the ProviderModal - * (e.g. when opening an existing instance in viewMode). + * to pre-fill the base_url/api_base form field when opening a saved + * instance. */ base_url?: string; + /** + * Provider-specific credentials that may be returned either at the top + * level or nested inside `api_key`: + * - group_id → MiniMax + * - api_version → Azure OpenAI + * - provider_order → OpenRouter + */ + group_id?: string; + api_version?: string; + provider_order?: string; } export interface IAddedModel { model_type: string[]; @@ -75,6 +91,20 @@ export interface IInstanceModel { model_type: string[]; name: string; status: string; + /** + * Persisted verification result from the backend: + * - `true` → verified successfully + * - `false` → verified but failed + * - `undefined` → never verified yet + */ + verify?: 'unknown' | 'success' | 'fail'; + /** + * Persisted Tool-call flag from `tenant_model.extra.is_tools`. + * The backend's `_hybrid_get_instance_models` includes this so the + * frontend can forward the correct value in auto-save payloads + * without relying solely on the (possibly unfetched) catalog. + */ + is_tools?: boolean; } export interface IDefaultModel { diff --git a/web/src/interfaces/request/llm.ts b/web/src/interfaces/request/llm.ts index 939bdfe9d7..b6dc7310ae 100644 --- a/web/src/interfaces/request/llm.ts +++ b/web/src/interfaces/request/llm.ts @@ -38,6 +38,13 @@ export interface IAddProviderRequestBody { export type IAddProviderInstanceRequestBody = IAddLlmRequestBody & { instance_name: string; region?: string; + /** + * Optional id of an existing instance. When present the backend + * treats the call as an update of that row (rather than a create), + * which is what the inline "blur-save" flow on saved instance cards + * needs. + */ + id?: string; }; export interface IDeleteProviderInstanceRequestBody { @@ -73,6 +80,42 @@ export interface IUpdateModelStatusRequestBody { status: 'active' | 'inactive'; } +/** + * Body shape for PATCH `/providers/{name}/instances/{name}/models/{model_name}`. + * All fields are optional; only the supplied keys are updated server-side. + */ +export interface IPatchInstanceModelRequestBody { + provider_name: string; + instance_name: string; + model_name: string; + status?: 'active' | 'inactive'; + max_tokens?: number; + model_type?: string[]; + extra?: Record; +} + +export interface IDeleteInstanceModelsRequestBody { + provider_name: string; + instance_name: string; + model_name: string[]; +} + +export interface IUpdateProviderInstanceRequestBody { + provider_name: string; + instance_name: string; + id?: string; + /** + * Either a plain API-key string, or — for providers that need an + * extra credential such as MiniMax's `group_id` — an object bundling + * the key with those fields: `{ api_key, group_id }`. + */ + api_key?: string | Record; + base_url?: string; + region?: string; + model_info?: IModelInfo[]; + verify?: boolean; +} + export interface ISetDefaultModelRequestBody { model_provider: string; model_instance: string; @@ -98,7 +141,7 @@ export interface IProviderModelItem { */ export interface IListProviderModelsRequestBody { provider_name: string; - api_key: string; + api_key?: string; base_url?: string; region?: string; model_info?: IModelInfo[]; diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 18dc64b563..a61265b63b 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1672,6 +1672,14 @@ Example: Virtual Hosted Style`, 'http://your-opendataloader-service:9383', modify: 'Modify', systemModelSettings: 'Set default models', + default: 'Default', + empty: 'No data', + docLink: 'Documentation link', + addInstance: 'Add instance', + addInstanceText: 'Add instance', + noInstancesConfigured: 'No instances configured yet.', + editInstanceName: 'Edit instance name', + models: 'Models', chatModel: 'LLM', chatModelTip: 'The default LLM for each newly created dataset.', embeddingModel: 'Embedding', @@ -1697,6 +1705,13 @@ Example: Virtual Hosted Style`, instanceNameMessage: 'Please input the instance name!', instanceNameTip: 'A unique name to identify this provider instance under the same factory.', + instanceNamePlaceholder: 'Please input instance name', + instanceNameSaveTip: + 'Enter an instance name and save it. Once saved, it cannot be changed.', + instanceNameSavePrompt: + 'Please save the instance name first before editing other fields.', + instanceNameLockedHint: 'Instance name is locked', + deleteInstance: 'Delete instance', modelName: 'Model name', modelID: 'Model ID', modelUid: 'Model UID', @@ -1900,6 +1915,8 @@ Example: Virtual Hosted Style`, 'Please select at least one model before verification.', addCustomModel: 'Add custom model', addCustomModelTitle: 'Add custom model', + batchAddModels: 'Add all visible models', + batchRemoveModels: 'Remove all visible models', editCustomModelTitle: 'Edit model', modelMaxTokens: 'Max tokens', modelFeatures: 'Model features', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 9d78e71d05..a7c3c7443f 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1366,6 +1366,14 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 'http://your-opendataloader-service:9383', modify: '修改', systemModelSettings: '设置默认模型', + default: '默认', + empty: '暂无数据', + docLink: '文档链接', + addInstance: '添加实例', + addInstanceText: '添加实例', + noInstancesConfigured: '尚未配置任何实例。', + editInstanceName: '编辑实例名称', + models: '模型', chatModel: 'LLM', chatModelTip: '所有新创建的知识库都会使用默认的聊天模型。', ttsModel: 'TTS', @@ -1390,6 +1398,11 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 instanceName: '实例名称', instanceNameMessage: '请输入实例名称!', instanceNameTip: '用于在同一厂商下唯一标识该实例的名称。', + instanceNamePlaceholder: '请输入实例名称', + instanceNameSaveTip: '请输入实例名称并保存。保存后不可修改。', + instanceNameSavePrompt: '请先保存实例名称,再编辑其他字段。', + instanceNameLockedHint: '实例名称已锁定', + deleteInstance: '删除实例', modelName: '模型名称', modelID: '模型ID', modelUid: '模型UID', @@ -1555,6 +1568,8 @@ NER:使用 spaCy NER 和基于规则的关键词提取来抽取实体和关系 selectModelBeforeVerify: '请至少选择一个模型后再验证。', addCustomModel: '添加自定义模型', addCustomModelTitle: '添加自定义模型', + batchAddModels: '批量添加当前模型', + batchRemoveModels: '批量移除当前模型', editCustomModelTitle: '编辑模型', modelMaxTokens: '最大 Token 数', modelTypes: { diff --git a/web/src/pages/user-setting/data-source/constant/s3-constant.tsx b/web/src/pages/user-setting/data-source/constant/s3-constant.tsx index 40223f93a4..da1cd347e9 100644 --- a/web/src/pages/user-setting/data-source/constant/s3-constant.tsx +++ b/web/src/pages/user-setting/data-source/constant/s3-constant.tsx @@ -1,6 +1,6 @@ import { FilterFormField, FormFieldType } from '@/components/dynamic-form'; import { TFunction } from 'i18next'; -import { BedrockRegionList } from '../../setting-model/constant'; +import { BedrockRegionList } from '../../setting-model/constants'; const awsRegionOptions = BedrockRegionList.map((r) => ({ label: r, diff --git a/web/src/pages/user-setting/setting-model/components/llm-header.tsx b/web/src/pages/user-setting/setting-model/components/llm-header.tsx deleted file mode 100644 index 0c90cf6b74..0000000000 --- a/web/src/pages/user-setting/setting-model/components/llm-header.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { LlmIcon } from '@/components/svg-icon'; -import { Button } from '@/components/ui/button'; -import { APIMapUrl } from '@/constants/llm'; -import { t } from 'i18next'; -import { ArrowUpRight, Plus } from 'lucide-react'; - -export const LLMHeader = ({ name }: { name: string }) => { - return ( -
- -
-
{name}
- {!!APIMapUrl[name as keyof typeof APIMapUrl] && ( - - )} -
- -
- ); -}; diff --git a/web/src/pages/user-setting/setting-model/components/used-model.tsx b/web/src/pages/user-setting/setting-model/components/used-model.tsx deleted file mode 100644 index 0e02a87b52..0000000000 --- a/web/src/pages/user-setting/setting-model/components/used-model.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; -import { LlmIcon } from '@/components/svg-icon'; -import { Button } from '@/components/ui/button'; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from '@/components/ui/collapsible'; -import { Switch } from '@/components/ui/switch'; -import { RAGFlowTooltip } from '@/components/ui/tooltip'; -import { ModelStatus } from '@/constants/llm'; -import { - useDeleteProviderInstance, - useEditInstanceModel, - useFetchAddedProviders, - useFetchInstanceModels, - useFetchProviderInstances, - useUpdateModelStatus, -} from '@/hooks/use-llm-request'; -import { - IAvailableProvider, - IInstanceModel, - IProviderInstance, -} from '@/interfaces/database/llm'; -import { IProviderModelItem } from '@/interfaces/request/llm'; -import { cn } from '@/lib/utils'; -import { ChevronsDown, ChevronsUp, Pencil, Trash2 } from 'lucide-react'; -import { useCallback, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { AddCustomModelDialog } from '../modal/provider-modal/components/add-custom-model-dialog'; -import { useCustomModelFields } from '../modal/provider-modal/components/use-custom-model-fields'; -import { mapModelKey } from './un-add-model'; - -export function UsedModel({ - handleAddModel, - onEditInstance, -}: { - handleAddModel: (factory: string) => void; - onEditInstance?: ( - providerName: string, - instance: IProviderInstance, - models: IInstanceModel[], - ) => void; -}) { - const { t } = useTranslation(); - const { data: providerList } = useFetchAddedProviders(); - - return ( -
-
- {t('setting.addedModels')} -
- {providerList.map((provider) => ( - - ))} -
- ); -} - -function ProviderCard({ - provider, - handleAddModel, - onEditInstance, -}: { - provider: IAvailableProvider; - handleAddModel: (factory: string) => void; - onEditInstance?: ( - providerName: string, - instance: IProviderInstance, - models: IInstanceModel[], - ) => void; -}) { - const { data: instances } = useFetchProviderInstances(provider.name); - if (!instances || instances.length <= 0) { - return null; - } - - return ( -
- {/* Provider header */} -
-
- -
- {provider.name} -
-
-
- {/* Instances */} - {instances.length > 0 && ( -
- {instances.map((instance) => ( - - ))} -
- )} -
- ); -} - -function InstanceRow({ - instance, - providerName, - // handleAddModel, - onEditInstance, -}: { - instance: IProviderInstance; - providerName: string; - handleAddModel: (factory: string) => void; - onEditInstance?: ( - providerName: string, - instance: IProviderInstance, - models: IInstanceModel[], - ) => void; -}) { - const { t } = useTranslation(); - const [visible, setVisible] = useState(false); - const { deleteProviderInstance } = useDeleteProviderInstance(); - - const handleDelete = async () => { - await deleteProviderInstance({ - provider_name: providerName, - instances: [instance.instance_name], - }); - }; - - return ( - -
- {/* Instance header */} -
- - {instance.instance_name} - -
- {/* */} - - - - - - -
-
- - {/* Models */} - - - -
-
- ); -} - -function InstanceModelList({ - providerName, - instanceName, - // instance, - // onEditInstance, -}: { - providerName: string; - instanceName: string; - instance: IProviderInstance; - onEditInstance?: ( - providerName: string, - instance: IProviderInstance, - models: IInstanceModel[], - ) => void; -}) { - const { data: models } = useFetchInstanceModels(providerName, instanceName); - // Lazily fetches the full instance details (incl. baseUrl) only when - // the user opens the settings dialog — keeps the collapsed section - // cheap and avoids the extra request for users who never click it. - // const { refetch: fetchInstanceDetails } = useFetchProviderInstance( - // providerName, - // instanceName, - // ); - - // const handleSettingsClick = useCallback(async () => { - // let details: IProviderInstance = instance; - // try { - // const ret = await fetchInstanceDetails(); - // if (ret.data) { - // details = { ...instance, ...(ret.data as IProviderInstance) }; - // } - // } catch { - // // Fall back to the list-level instance data if the show request - // // fails (e.g. network error) — the modal still gets a usable - // // baseline. - // } - // onEditInstance?.(providerName, details, models); - // }, [fetchInstanceDetails, instance, models, onEditInstance, providerName]); - - const modelTypes = useMemo(() => { - const types = new Set(); - models.forEach((m) => { - if (m.model_type) { - m.model_type.forEach((type) => types.add(type)); - } - }); - return Array.from(types); - }, [models]); - - return ( -
- {/* Model type tags */} - {modelTypes.length > 0 && ( -
-
- {modelTypes.map((type) => ( - - {mapModelKey[type.trim() as keyof typeof mapModelKey] || type} - - ))} -
- {/* */} -
- )} - - {/* Model list */} -
-
    - {models.map((model) => ( - - ))} -
-
-
- ); -} - -function ModelListItem({ - model, - providerName, - instanceName, -}: { - model: IInstanceModel; - providerName: string; - instanceName: string; -}) { - const { t } = useTranslation(); - const { updateModelStatus } = useUpdateModelStatus(); - const { editInstanceModel, loading: editLoading } = useEditInstanceModel(); - const customModelFields = useCustomModelFields(); - const [editOpen, setEditOpen] = useState(false); - - const handleStatusChange = (checked: boolean) => { - updateModelStatus({ - provider_name: providerName, - instance_name: instanceName, - model_name: model.name, - status: checked ? ModelStatus.Active : ModelStatus.Inactive, - }); - }; - - // Only show name / model_types / max_tokens; only model_types is editable. - const editFields = useMemo( - () => - customModelFields - .filter((f) => ['name', 'model_types', 'max_tokens'].includes(f.name)) - .map((f) => ({ - ...f, - disabled: f.name !== 'model_types', - })), - [customModelFields], - ); - - const editDefaultValues = useMemo( - () => ({ - name: model.name, - model_types: model.model_type ?? [], - max_tokens: model.max_tokens ?? 0, - }), - [model], - ); - - const handleEditSubmit = useCallback( - async (item: IProviderModelItem) => { - await editInstanceModel({ - provider_name: providerName, - instance_name: instanceName, - model_name: [model.name], - model_type: item.model_types ?? [], - }); - setEditOpen(false); - }, - [editInstanceModel, providerName, instanceName, model.name], - ); - - return ( -
  • -
    - {model.name} - {model.model_type.slice(0, 3).map((modelType) => ( - - {modelType} - - ))} - {model.model_type.length > 3 && ( - - {model.model_type.slice(3).map((type) => ( - {type} - ))} -
    - } - > - - ... - - - )} - - - - -
  • - ); -} diff --git a/web/src/pages/user-setting/setting-model/constant.ts b/web/src/pages/user-setting/setting-model/constants.ts similarity index 51% rename from web/src/pages/user-setting/setting-model/constant.ts rename to web/src/pages/user-setting/setting-model/constants.ts index 1e2141911a..cf5de66db9 100644 --- a/web/src/pages/user-setting/setting-model/constant.ts +++ b/web/src/pages/user-setting/setting-model/constants.ts @@ -1,3 +1,19 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export const BedrockRegionList = [ 'us-east-2', 'us-east-1', diff --git a/web/src/pages/user-setting/setting-model/hooks.tsx b/web/src/pages/user-setting/setting-model/hooks.tsx index 458aa310a8..173baf1335 100644 --- a/web/src/pages/user-setting/setting-model/hooks.tsx +++ b/web/src/pages/user-setting/setting-model/hooks.tsx @@ -1,4 +1,19 @@ -import { LLMFactory } from '@/constants/llm'; +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { useSetModalState } from '@/hooks/common-hooks'; import { useAddInstanceModel, @@ -129,7 +144,9 @@ export const useVerifyConnection = () => { }; // ============ Hooks for retained special modals ============ -// Bedrock and SoMark still use custom modal components. +// Bedrock and SoMark have been migrated to inline instance cards +// (BedrockInstanceCard / SoMarkInstanceCard); these legacy modal +// hooks are kept only for backward-compat references. export const useSubmitBedrock = () => { const [saveLoading, setSaveLoading] = useState(false); @@ -185,106 +202,3 @@ export const useSubmitBedrock = () => { showBedrockAddingModal, }; }; - -export const useSubmitSoMark = () => { - const [saveLoading, setSaveLoading] = useState(false); - const submitProviderInstance = useSubmitProviderInstance(); - const verifyConnection = useVerifyConnection(); - const { - visible: somarkVisible, - hideModal: hideSoMarkModal, - showModal: showSoMarkModal, - } = useSetModalState(); - - const onSoMarkOk = useCallback( - async (payload: any, isVerify = false) => { - if (!isVerify) { - setSaveLoading(true); - } - const req = { - instance_name: payload.instance_name, - llm_factory: LLMFactory.SoMark, - api_key: payload.somark_api_key || '', - base_url: payload.somark_base_url, - max_tokens: 0, - model_info: [ - { - model_name: payload.llm_name, - model_type: ['ocr'], - max_tokens: 0, - extra: { - somark_image_format: payload.somark_image_format, - somark_formula_format: payload.somark_formula_format, - somark_table_format: payload.somark_table_format, - somark_cs_format: payload.somark_cs_format, - somark_enable_text_cross_page: - payload.somark_enable_text_cross_page, - somark_enable_table_cross_page: - payload.somark_enable_table_cross_page, - somark_enable_title_level_recognition: - payload.somark_enable_title_level_recognition, - somark_enable_inline_image: payload.somark_enable_inline_image, - somark_enable_table_image: payload.somark_enable_table_image, - somark_enable_image_understanding: - payload.somark_enable_image_understanding, - somark_keep_header_footer: payload.somark_keep_header_footer, - }, - }, - ], - }; - try { - if (isVerify) { - return verifyConnection( - LLMFactory.SoMark, - req.api_key, - req.base_url, - undefined, - req.model_info as IModelInfo[], - ); - } - const ret = await submitProviderInstance( - req as IAddProviderInstanceRequestBody, - false, - ); - if (ret.code === 0) { - hideSoMarkModal(); - return true; - } - return false; - } finally { - if (!isVerify) { - setSaveLoading(false); - } - } - }, - [submitProviderInstance, hideSoMarkModal, setSaveLoading, verifyConnection], - ); - - return { - somarkVisible, - hideSoMarkModal, - showSoMarkModal, - onSoMarkOk, - somarkLoading: saveLoading, - }; -}; - -/** - * Wraps the verify callback: provides a unified call with isVerify=true for the Verify button - */ -export const useVerifySettings = ({ - onVerify, -}: { - onVerify: (postBody: any, isVerify?: boolean) => Promise; -}) => { - const onApiKeyVerifying = useCallback( - async (postBody: any) => { - const res = await onVerify(postBody, true); - return res; - }, - [onVerify], - ); - return { - onApiKeyVerifying, - }; -}; diff --git a/web/src/pages/user-setting/setting-model/index.less b/web/src/pages/user-setting/setting-model/index.less index a415b02735..1767925320 100644 --- a/web/src/pages/user-setting/setting-model/index.less +++ b/web/src/pages/user-setting/setting-model/index.less @@ -1,3 +1,19 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + .modelContainer { width: 100%; .factoryOperationWrapper { diff --git a/web/src/pages/user-setting/setting-model/index.tsx b/web/src/pages/user-setting/setting-model/index.tsx index e5254dc4c3..7d81c4701f 100644 --- a/web/src/pages/user-setting/setting-model/index.tsx +++ b/web/src/pages/user-setting/setting-model/index.tsx @@ -1,409 +1,238 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import Spotlight from '@/components/spotlight'; -import { LLMFactory } from '@/constants/llm'; +import { useTranslate } from '@/hooks/common-hooks'; import { - useAddInstanceModel, + LlmKeys, useAddProviderInstance, - useFetchAvailableProviders, - useVerifyProviderConnection, + useFetchProviderInstances, } from '@/hooks/use-llm-request'; -import { IInstanceModel, IProviderInstance } from '@/interfaces/database/llm'; -import type { - IAddProviderInstanceRequestBody, - IModelInfo, -} from '@/interfaces/request/llm'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { isLocalLlmFactory } from '../utils'; -import SystemSetting from './components/system-setting'; -import { AvailableModels } from './components/un-add-model'; -import { UsedModel } from './components/used-model'; -import { useSubmitBedrock, useSubmitSoMark, useVerifySettings } from './hooks'; -import BedrockModal from './modal/bedrock-modal'; -import ProviderModal, { IViewModeOkPayload } from './modal/provider-modal'; -import SoMarkModal from './modal/somark-modal'; -import { splitProviderPayload } from './payload-utils'; +import { IProviderInstance } from '@/interfaces/database/llm'; +import { useQueryClient } from '@tanstack/react-query'; +import { Plus } from 'lucide-react'; +import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ProviderInstanceCard } from './instance-card/provider-instance-card'; +import { ProviderHeaderBar } from './layout/provider-header-bar'; +import { Sidebar, SidebarSelection } from './layout/sidebar'; +import SystemSetting from './layout/system-setting'; -const ModelProviders = () => { - // Retained special modals - const { - bedrockAddingLoading, - onBedrockAddingOk, - bedrockAddingVisible, - hideBedrockAddingModal, - showBedrockAddingModal, - } = useSubmitBedrock(); +/** + * Sidebar-driven model provider settings page. + * + * Layout: + * - Left: `Sidebar` (Default-models entry, search, provider list). + * - Right: + * * 'default' selection -> `SystemSetting`. + * * provider selection -> a sticky `ProviderHeaderBar` at the top, + * a vertical stack of `ProviderInstanceCard` in the middle, and + * a sticky "+ Instance" button at the bottom. Each click of + * that button adds a new draft card; multiple drafts can coexist + * and be saved / cancelled independently. + * + * Special-case providers (handled inside `ProviderInstanceCard`): + * - `Bedrock`: rendered inline via `BedrockInstanceCard`. + * - `SoMark`: rendered inline via `SoMarkInstanceCard`. + */ +const SettingModelV2: FC = () => { + const { t: tSetting } = useTranslate('setting'); + const [selection, setSelection] = useState('default'); + // Stack of draft-instance identifiers, rendered as `ProviderInstanceCard` + // entries below the persisted instances. Each draft can be saved or + // cancelled independently; saving removes the draft from this list and + // triggers a refetch that surfaces the newly-saved instance above. + const [draftIds, setDraftIds] = useState([]); + // Monotonic counter so each draft card has a stable, unique React key. + const draftIdCounterRef = useRef(0); + // Tracks whether the user explicitly cancelled the auto-shown draft + // for the current selection. Reset on every selection change. + const cancelledRef = useRef(false); - // Unified ProviderModal state - const [providerVisible, setProviderVisible] = useState(false); - const [currentLlmFactory, setCurrentLlmFactory] = useState(''); - const [providerLoading, setProviderLoading] = useState(false); + // Always re-fetch when the selection changes. Passing an empty string + // disables the query. + const providerQueryName = selection === 'default' ? '' : selection; + const { data: instances, loading: instancesLoading } = + useFetchProviderInstances(providerQueryName); + const queryClient = useQueryClient(); - // viewMode (edit-models) state: when true, ProviderModal opens in - // read-only mode for everything except the model-related fields. - // `viewModeInitialValues` carries the existing instance + model data. - const [viewMode, setViewMode] = useState(false); - const [viewModeInitialValues, setViewModeInitialValues] = useState< - Record | undefined - >(undefined); - - // ProviderModal submission logic: calls addProviderInstance + addInstanceModel - const { addProviderInstance } = useAddProviderInstance(); - const { addInstanceModel } = useAddInstanceModel(); - const { verifyProviderConnection } = useVerifyProviderConnection(); - const { data: availableProviders } = useFetchAvailableProviders(); - - // Convert IAvailableProvider.url to baseUrlOptions - // IAvailableProvider.url looks like { default?: string; cn?: string; intl?: string; ... } - // Mapped to [{ value: 'https://...', regionKey: 'default', label: https://...default }, ...] - // `regionKey` carries the original key so the modal can map the currently - // selected URL back to its key for the `region` submit field. - const buildBaseUrlOptions = useCallback( - (urlObj?: Record) => { - if (!urlObj) return undefined; - const options = Object.keys(urlObj) - .filter((k) => !!urlObj[k]) - .map((k) => { - const v = urlObj[k] as string; - // if (k === 'default') { - // return { value: v, label: v }; - // } - return { - value: v, - regionKey: k, - label: ( -
    - {v} - - {k} - -
    - ), - }; - }); - return options.length > 0 ? options : undefined; - }, - [], - ); - - // baseUrlOptions for the current factory (looked up from availableProviders) - const currentProvider = useMemo( - () => - currentLlmFactory - ? availableProviders.find((p) => p.name === currentLlmFactory) - : undefined, - [availableProviders, currentLlmFactory], - ); - const currentBaseUrlOptions = useMemo( - () => buildBaseUrlOptions(currentProvider?.url), - [buildBaseUrlOptions, currentProvider], - ); - - const handleProviderOk = useCallback( - async (payload: IAddProviderInstanceRequestBody, isVerify = false) => { - if (!isVerify) setProviderLoading(true); - try { - if (isVerify) { - // Verify mode: call verify API - const ret = await addProviderInstance({ ...payload, verify: true }); - return ret; - } - // Normal submission - const { instancePayload, modelPayload } = splitProviderPayload(payload); - const hasModelPayload = - !!modelPayload.model_name && !!modelPayload.model_type; - const instanceRet = await addProviderInstance({ - ...instancePayload, - llm_factory: payload.llm_factory, - instance_name: payload.instance_name, - } as IAddProviderInstanceRequestBody); - if (instanceRet.code !== 0) { - return instanceRet; - } - // When model information has been submitted nested in the instance via model_info - // (e.g., VolcEngine / LocalLLM), addInstanceModel is no longer called separately; - // close the modal directly. - if (!hasModelPayload) { - setProviderVisible(false); - return instanceRet; - } - const modelRet = await addInstanceModel({ - provider_name: payload.llm_factory, - instance_name: payload.instance_name, - ...modelPayload, - }); - if (modelRet.code === 0) { - setProviderVisible(false); - } - return modelRet; - } finally { - if (!isVerify) setProviderLoading(false); - } - }, - [addProviderInstance, addInstanceModel], - ); - - useEffect(() => { - if (!providerVisible) { - setProviderLoading(false); - } - }, [providerVisible]); - - const handleProviderVerify = useCallback( - async (params: any) => { - // ProviderModal's handleVerify flattens verifyArgs onto params - // verifyArgs comes from config.verifyTransform, fields are apiKey/baseUrl/region/modelInfo - const apiKey = params.apiKey ?? params.api_key ?? params._apiKey ?? ''; - const baseUrl = params.baseUrl ?? params.base_url ?? params._baseUrl; - const region = params.region ?? params._region; - const modelInfo = - params.modelInfo ?? params.model_info ?? params._modelInfo; - const ret = await verifyProviderConnection({ - provider_name: params.llm_factory ?? currentLlmFactory, - api_key: apiKey, - base_url: baseUrl, - region: region, - model_info: modelInfo, - }); - if (ret.code === 0) { - return { isValid: true, logs: ret.message }; - } - return { isValid: false, logs: ret.message }; - }, - [verifyProviderConnection, currentLlmFactory], - ); - - const { - somarkVisible, - hideSoMarkModal, - showSoMarkModal, - onSoMarkOk, - somarkLoading, - } = useSubmitSoMark(); - - const ModalMap = useMemo( - () => ({ - [LLMFactory.Bedrock]: showBedrockAddingModal, - [LLMFactory.VolcEngine]: () => { - setCurrentLlmFactory(LLMFactory.VolcEngine); - setProviderVisible(true); - }, - [LLMFactory.XunFeiSpark]: () => { - setCurrentLlmFactory(LLMFactory.XunFeiSpark); - setProviderVisible(true); - }, - [LLMFactory.BaiduYiYan]: () => { - setCurrentLlmFactory(LLMFactory.BaiduYiYan); - setProviderVisible(true); - }, - [LLMFactory.FishAudio]: () => { - setCurrentLlmFactory(LLMFactory.FishAudio); - setProviderVisible(true); - }, - [LLMFactory.TencentCloud]: () => { - setCurrentLlmFactory(LLMFactory.TencentCloud); - setProviderVisible(true); - }, - [LLMFactory.GoogleCloud]: () => { - setCurrentLlmFactory(LLMFactory.GoogleCloud); - setProviderVisible(true); - }, - [LLMFactory.AzureOpenAI]: () => { - setCurrentLlmFactory(LLMFactory.AzureOpenAI); - setProviderVisible(true); - }, - [LLMFactory.MinerU]: () => { - setCurrentLlmFactory(LLMFactory.MinerU); - setProviderVisible(true); - }, - [LLMFactory.PaddleOCR]: () => { - setCurrentLlmFactory(LLMFactory.PaddleOCR); - setProviderVisible(true); - }, - [LLMFactory.OpenDataLoader]: () => { - setCurrentLlmFactory(LLMFactory.OpenDataLoader); - setProviderVisible(true); - }, - [LLMFactory.SoMark]: showSoMarkModal, - }), - [showBedrockAddingModal, showSoMarkModal], - ); - - const handleAddModel = useCallback( - (llmFactory: string) => { - if (isLocalLlmFactory(llmFactory)) { - setCurrentLlmFactory(llmFactory); - setProviderVisible(true); - } else if (llmFactory in ModalMap) { - ModalMap[llmFactory as keyof typeof ModalMap](); - } else { - setCurrentLlmFactory(llmFactory); - setProviderVisible(true); - } - }, - [ModalMap], - ); - - // Open the ProviderModal in viewMode (read-only) for an existing - // instance so the user can edit its model list. The instance's - // `api_key`, `baseUrl` and `model_info` are passed as initial values; - // the list picker uses `model_info` to pre-check the already-added - // models. - const handleEditInstance = useCallback( - ( - providerName: string, - instance: IProviderInstance, - models: IInstanceModel[], - ) => { - setCurrentLlmFactory(providerName); - const modelInfos: IModelInfo[] = models.map((m) => ({ - model_name: m.name, - model_type: m.model_type, - max_tokens: m.max_tokens ?? 0, - })); - // For non-LIST_MODEL_PROVIDERS, the modal renders model_name / - // model_type / max_tokens / is_tools as form fields, so seed - // them from the first existing model to match what the user sees - // in the instance list. - const firstModel = models[0]; - setViewModeInitialValues({ - instance_name: instance.instance_name, - api_key: instance.api_key, - // baseUrl is only present when the showProviderInstance endpoint - // returned it; pass it as both `base_url` and `api_base` so it - // fills the form field regardless of which name the provider - // config uses. - ...(instance.base_url - ? { base_url: instance.base_url, api_base: instance.base_url } - : {}), - ...(firstModel - ? { - model_name: firstModel.name, - model_type: firstModel.model_type, - max_tokens: firstModel.max_tokens, - } - : {}), - model_info: modelInfos, - }); - setViewMode(true); - setProviderVisible(true); - }, - [], - ); - - // viewMode save handler: receives the list of selected models (or - // the editable model fields for non-LIST_MODEL_PROVIDERS) from the - // modal and adds them via `addInstanceModel`. Does NOT call - // `addProviderInstance` because the instance itself is unchanged. - const handleViewModeOk = useCallback( - async (payload: IViewModeOkPayload) => { - setProviderLoading(true); - try { - if (payload.modelInfos.length > 0) { - // LIST_MODEL_PROVIDERS: full sync — call addInstanceModel for - // every selected model. The backend is idempotent so re-adding - // an already-present model is a no-op. - for (const model of payload.modelInfos) { - const modelType = Array.isArray(model.model_type) - ? model.model_type - : model.model_type - ? [model.model_type as string] - : []; - const ret = await addInstanceModel({ - provider_name: payload.llmFactory, - instance_name: payload.instanceName, - model_name: model.model_name, - model_type: modelType, - max_tokens: model.max_tokens ?? 0, - ...(model.extra ? { extra: model.extra } : {}), - }); - if (ret.code !== 0) { - return ret; - } - } - } else if (payload.formValues) { - // Non-LIST_MODEL_PROVIDERS: add/update the single model - // described by the form values. - const fv = payload.formValues; - const modelType = Array.isArray(fv.model_type) - ? fv.model_type - : fv.model_type - ? [fv.model_type as string] - : []; - const ret = await addInstanceModel({ - provider_name: payload.llmFactory, - instance_name: payload.instanceName, - model_name: fv.model_name, - model_type: modelType, - max_tokens: fv.max_tokens ?? 0, - ...(fv.is_tools !== undefined - ? { extra: { is_tools: !!fv.is_tools } } - : {}), - }); - if (ret.code !== 0) { - return ret; - } - } - setProviderVisible(false); - } finally { - setProviderLoading(false); - } - }, - [addInstanceModel], - ); - - // Closing the modal also clears the viewMode flag so the next open - // starts in the default (add) mode. - const hideProviderModal = useCallback(() => { - setProviderVisible(false); - setViewMode(false); + // Append a new draft id to the visible list. + const addDraft = useCallback(() => { + draftIdCounterRef.current += 1; + const id = `draft-${draftIdCounterRef.current}`; + setDraftIds((prev) => [...prev, id]); }, []); - const { onApiKeyVerifying: onSoMarkVerifying } = useVerifySettings({ - onVerify: onSoMarkOk, - }); + // Remove a draft id from the visible list (called on save / cancel). + const removeDraft = useCallback((id: string) => { + setDraftIds((prev) => prev.filter((d) => d !== id)); + }, []); + + // When the selection changes, clear the cancelled flag and drop any + // in-flight drafts so the user starts fresh on the new provider. + useEffect(() => { + cancelledRef.current = false; + setDraftIds([]); + }, [selection]); + + // If the user switches to a provider with no existing instances and + // no drafts already on screen, auto-show a "new instance" draft so + // they can fill it in immediately. If they have explicitly cancelled, + // do not re-show. + // + // Wait for the provider-instances query to settle before deciding: + // `initialData: []` on the hook means `instances` is an empty array + // from the first render, so a naive length check would auto-spawn a + // draft during the brief loading window even when the provider + // already has saved instances, only to remove it once the query + // resolves. Gating on `!instancesLoading` skips that flicker and + // keeps the UI clean for providers that do have instances. + useEffect(() => { + if (selection === 'default' || cancelledRef.current) return; + if (instancesLoading) return; + if (instances.length === 0 && draftIds.length === 0) { + addDraft(); + } + }, [selection, instances, instancesLoading, draftIds, addDraft]); + + const { addProviderInstance } = useAddProviderInstance(); + + // Save handler for a draft card. Calls `addProviderInstance` with the + // values supplied by the draft form (instance_name, api_key, base_url, + // model_info...). After a successful save, removes the draft from the + // list and invalidates the instance query so the new card appears in + // the persisted list automatically. + const handleDraftSave = useCallback( + async (id: string, values: Record) => { + const ret = await addProviderInstance({ + llm_factory: selection as string, + instance_name: values.instance_name, + api_key: values.api_key, + base_url: values.base_url ?? values.api_base, + model_info: values.model_info, + } as any); + if (ret?.code === 0) { + // Mark this selection as "user-engaged" so the auto-show effect + // below does not spawn another draft while the providerInstances + // refetch is still in flight (during that window both `instances` + // and `draftIds` are empty and would otherwise re-trigger + // `addDraft()`). + cancelledRef.current = true; + removeDraft(id); + queryClient.invalidateQueries({ + queryKey: LlmKeys.providerInstances(providerQueryName), + }); + } + }, + [ + addProviderInstance, + selection, + queryClient, + providerQueryName, + removeDraft, + ], + ); + + // The instance name has just been persisted (via the dedicated + // "Save name" button inside the draft card). Remove the draft and + // pin the auto-show guard so a new placeholder draft is not + // auto-injected during the brief window between draft removal and + // the providerInstances refetch landing. + const handleNameSaved = useCallback( + (id: string) => { + cancelledRef.current = true; + removeDraft(id); + }, + [removeDraft], + ); + + // User clicked Cancel on a specific draft — remove it from the list + // and stop the auto-show effect from re-opening it for the current + // empty-instance selection. + const handleDraftCancel = useCallback( + (id: string) => { + cancelledRef.current = true; + removeDraft(id); + }, + [removeDraft], + ); + + const draftInstance: IProviderInstance = useMemo( + () => ({ instance_name: '' }) as IProviderInstance, + [], + ); return ( -
    +
    -
    - - +
    +
    -
    - +
    + {selection === 'default' ? ( +
    + +
    + ) : ( + <> + {/* Sticky top: provider name + doc-link arrow */} + + + {/* Scrollable middle: instance cards + optional draft cards */} +
    + {instances.length === 0 && draftIds.length === 0 && ( +
    + {tSetting('noInstancesConfigured')} +
    + )} + {instances.map((instance, index) => ( + + ))} + {draftIds.map((id) => ( + handleDraftCancel(id)} + onNameSaved={() => handleNameSaved(id)} + onSaved={(values) => handleDraftSave(id, values)} + /> + ))} +
    + +
    +
    + + )}
    - {/* Unified ProviderModal (replaces 9 independent modals) */} - - onBedrockAddingOk(payload, true)} - > -
    ); }; -export default ModelProviders; +export default SettingModelV2; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/components/add-custom-model-dialog.tsx b/web/src/pages/user-setting/setting-model/instance-card/add-custom-model-dialog.tsx similarity index 92% rename from web/src/pages/user-setting/setting-model/modal/provider-modal/components/add-custom-model-dialog.tsx rename to web/src/pages/user-setting/setting-model/instance-card/add-custom-model-dialog.tsx index 654f246cad..494238b6ee 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/components/add-custom-model-dialog.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/add-custom-model-dialog.tsx @@ -1,3 +1,19 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + 'use client'; import { diff --git a/web/src/pages/user-setting/setting-model/components/un-add-model.tsx b/web/src/pages/user-setting/setting-model/instance-card/available-models.tsx similarity index 90% rename from web/src/pages/user-setting/setting-model/components/un-add-model.tsx rename to web/src/pages/user-setting/setting-model/instance-card/available-models.tsx index 1039c7c97f..c011c9cf44 100644 --- a/web/src/pages/user-setting/setting-model/components/un-add-model.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/available-models.tsx @@ -1,3 +1,19 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + // src/components/AvailableModels.tsx import { LlmIcon } from '@/components/svg-icon'; import { Button } from '@/components/ui/button'; @@ -43,7 +59,7 @@ type ModelType = | 'vision' | 'ocr'; -const sortModelTypes = (modelTypes: string[]) => { +export const sortModelTypes = (modelTypes: string[]) => { return [...modelTypes].sort( (a, b) => (orderMap[a as ModelType] || 999) - (orderMap[b as ModelType] || 999), diff --git a/web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx new file mode 100644 index 0000000000..46f48e89a9 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/bedrock-instance-card.tsx @@ -0,0 +1,783 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; +import { SelectWithSearch } from '@/components/originui/select-with-search'; +import { RAGFlowFormItem } from '@/components/ragflow-form'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Form } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { MultiSelect } from '@/components/ui/multi-select'; +import { Segmented } from '@/components/ui/segmented'; +import { useTranslate } from '@/hooks/common-hooks'; +import { useBuildModelTypeOptions } from '@/hooks/logic-hooks/use-build-options'; +import { + useAddProviderInstance, + useDeleteProviderInstance, + useFetchProviderInstance, + useVerifyProviderConnection, +} from '@/hooks/use-llm-request'; +import { IProviderInstance } from '@/interfaces/database/llm'; +import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useForm, useWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { BedrockRegionList } from '../constants'; +import { VerifyResult } from '../hooks'; +import { splitProviderPayload } from '../payload-utils'; +import { ModelsSection } from './models-section'; +import VerifyButton from './verify-button'; + +type AuthMode = 'access_key_secret' | 'iam_role' | 'assume_role'; + +type BedrockFormValues = { + auth_mode: AuthMode; + bedrock_ak?: string; + bedrock_sk?: string; + aws_role_arn?: string; + bedrock_region: string; + llm_name: string; + max_tokens: number; + model_type: ('chat' | 'embedding')[]; +}; + +// Field names whose value commits via click (Segmented, Select, +// MultiSelect) rather than blur. Their popovers render in Radix +// portals outside the card's blur container, so blur-driven saves +// don't catch them — a form.watch watcher is used instead. +const BEDROCK_WATCHED_FIELDS = new Set([ + 'auth_mode', + 'bedrock_region', + 'model_type', +]); + +interface BedrockInstanceCardProps { + providerName: string; + instance: IProviderInstance; + isDraft?: boolean; + onSaved?: (values: Record) => void | Promise; + onNameSaved?: () => void; + onDelete?: () => void; + /** + * When true, this card starts expanded and fetches its instance + * details on mount. Default `false` so non-first cards stay + * collapsed until the user opens them. + */ + defaultOpen?: boolean; +} + +/** + * Inline instance card for AWS Bedrock. Mirrors the two-stage UX of + * `ProviderInstanceCard` (save name first, then edit fields) but renders + * Bedrock-specific fields (auth_mode segmented, ak/sk/arn, region, model + * name, max tokens, model_type) directly instead of going through the + * generic DynamicForm path. + */ +export function BedrockInstanceCard({ + providerName, + instance, + isDraft = false, + onSaved, + onNameSaved, + onDelete, + defaultOpen = false, +}: BedrockInstanceCardProps) { + const { t } = useTranslation(); + const { t: tSetting } = useTranslate('setting'); + const { buildModelTypeOptions } = useBuildModelTypeOptions(); + const [open, setOpen] = useState(isDraft || defaultOpen); + const [draftName, setDraftName] = useState(''); + const [nameSaved, setNameSaved] = useState(!isDraft); + const savingRef = useRef(false); + + useEffect(() => { + if (isDraft) { + setDraftName(''); + setNameSaved(false); + } else { + setNameSaved(true); + } + }, [providerName, isDraft]); + + const FormSchema = useMemo( + () => + z + .object({ + auth_mode: z + .enum(['access_key_secret', 'iam_role', 'assume_role']) + .default('access_key_secret'), + bedrock_ak: z.string().optional(), + bedrock_sk: z.string().optional(), + aws_role_arn: z.string().optional(), + bedrock_region: z + .string() + .min(1, { message: tSetting('bedrockRegionMessage') }), + llm_name: z + .string() + .min(1, { message: tSetting('bedrockModelNameMessage') }), + max_tokens: z + .number({ + required_error: tSetting('maxTokensMessage'), + invalid_type_error: tSetting('maxTokensInvalidMessage'), + }) + .nonnegative({ message: tSetting('maxTokensMinMessage') }), + model_type: z + .array(z.enum(['chat', 'embedding'])) + .min(1, { message: tSetting('modelTypeMessage') }), + }) + .superRefine((data, ctx) => { + if (data.auth_mode === 'access_key_secret') { + if (!data.bedrock_ak || !data.bedrock_ak.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: tSetting('bedrockAKMessage'), + path: ['bedrock_ak'], + }); + } + if (!data.bedrock_sk || !data.bedrock_sk.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: tSetting('bedrockSKMessage'), + path: ['bedrock_sk'], + }); + } + } + if (data.auth_mode === 'iam_role') { + if (!data.aws_role_arn || !data.aws_role_arn.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: tSetting('awsRoleArnMessage'), + path: ['aws_role_arn'], + }); + } + } + }), + [tSetting], + ); + + const { data: instanceDetails, refetch: refetchInstanceDetails } = + useFetchProviderInstance( + isDraft ? '' : providerName, + isDraft ? '' : instance.instance_name, + ); + + // Lazily fetch full instance details only when the card is open. + // Mirrors the generic ProviderInstanceCard: collapsed cards never + // hit /providers//instances/; expanding one + // triggers a fresh refetch. + useEffect(() => { + if (!isDraft && open && providerName && instance.instance_name) { + refetchInstanceDetails(); + } + }, [ + isDraft, + open, + providerName, + instance.instance_name, + refetchInstanceDetails, + ]); + + const initialValues = useMemo(() => { + const merged = { ...instance, ...(instanceDetails ?? {}) } as any; + const apiKey = + merged.api_key && typeof merged.api_key === 'object' + ? merged.api_key + : {}; + return { + auth_mode: (apiKey.auth_mode as AuthMode) ?? 'access_key_secret', + bedrock_ak: apiKey.bedrock_ak ?? '', + bedrock_sk: apiKey.bedrock_sk ?? '', + aws_role_arn: apiKey.aws_role_arn ?? '', + bedrock_region: + merged.region && merged.region !== 'default' ? merged.region : '', + llm_name: '', + max_tokens: 8192, + model_type: ['chat'], + }; + }, [instance, instanceDetails]); + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: initialValues, + }); + + useEffect(() => { + // Reset form when initial values change (e.g. instance details load). + form.reset(initialValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues]); + + const authMode = useWatch({ control: form.control, name: 'auth_mode' }); + + const regionOptions = useMemo( + () => BedrockRegionList.map((x) => ({ value: x, label: tSetting(x) })), + [tSetting], + ); + + // Build a Bedrock-shaped payload for both submit and verify flows. + const buildPayload = useCallback( + (values: BedrockFormValues, instanceName: string) => { + const cleaned: Record = { ...values }; + const fieldsByMode: Record = { + access_key_secret: ['bedrock_ak', 'bedrock_sk'], + iam_role: ['aws_role_arn'], + assume_role: [], + }; + (Object.keys(fieldsByMode) as AuthMode[]).forEach((mode) => { + if (mode !== values.auth_mode) { + fieldsByMode[mode].forEach((f) => { + delete cleaned[f]; + }); + } + }); + + const flat = { + ...cleaned, + instance_name: instanceName, + llm_factory: providerName, + max_tokens: values.max_tokens, + model_type: values.model_type, + }; + const { instancePayload, modelPayload } = splitProviderPayload(flat); + return { + ...instancePayload, + max_tokens: modelPayload.max_tokens, + model_info: [modelPayload], + } as IAddProviderInstanceRequestBody; + }, + [providerName], + ); + + const { verifyProviderConnection } = useVerifyProviderConnection(); + const handleVerify = useCallback( + async (params: any) => { + const isValid = await form.trigger(); + if (!isValid) { + return { + isValid: false, + logs: tSetting('bedrockRegionMessage'), + } as VerifyResult; + } + const values = form.getValues(); + const payload = buildPayload( + values, + draftName.trim() || instance.instance_name, + ); + const { instancePayload, modelPayload } = splitProviderPayload({ + ...payload, + ...values, + llm_factory: providerName, + instance_name: draftName.trim() || instance.instance_name, + }); + const ret = await verifyProviderConnection({ + provider_name: providerName, + api_key: JSON.stringify(instancePayload.api_key), + base_url: instancePayload.base_url, + region: instancePayload.region, + model_info: [modelPayload], + ...params, + }); + return { + isValid: ret.code === 0, + logs: ret.message, + } as VerifyResult; + }, + [ + form, + providerName, + buildPayload, + draftName, + instance.instance_name, + verifyProviderConnection, + tSetting, + ], + ); + + const { addProviderInstance } = useAddProviderInstance(); + + const handleSaveName = useCallback(async () => { + const trimmed = draftName.trim(); + if (!trimmed) return; + const ret = await addProviderInstance({ + llm_factory: providerName, + instance_name: trimmed, + } as any); + if (ret?.code === 0) { + onNameSaved?.(); + } + }, [draftName, addProviderInstance, providerName, onNameSaved]); + + // Auto-save in draft mode after the name is locked. Debounced on form + // value changes; refuses to fire until validation passes. + useEffect(() => { + if (!isDraft) return; + if (!nameSaved) return; + let saveTimeout: ReturnType | null = null; + let cancelled = false; + const sub = form.watch(() => { + if (saveTimeout) clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + if (cancelled || savingRef.current) return; + const isValid = await form.trigger(); + if (cancelled || savingRef.current) return; + if (!isValid) return; + const trimmed = draftName.trim(); + if (!trimmed) return; + savingRef.current = true; + try { + const values = form.getValues(); + const payload = buildPayload(values, trimmed); + await onSaved?.(payload as unknown as Record); + } finally { + savingRef.current = false; + } + }, 200); + }); + return () => { + cancelled = true; + if (saveTimeout) clearTimeout(saveTimeout); + try { + sub?.unsubscribe?.(); + } catch { + // ignore + } + }; + }, [isDraft, nameSaved, form, draftName, buildPayload, onSaved]); + + // Saved-mode auto-save. Both blur-driven (text inputs) and + // change-driven (Segmented / Select / MultiSelect) edits are + // coalesced through a shared debounced `scheduleSave`. Selects render + // in Radix portals outside the card's blur container, so blur-driven + // saves don't catch them — a form.watch watcher is used instead. + const blurSavingRef = useRef(false); + // Flipped to true while a child (e.g. ModelsSection's + // AddCustomModelDialog) opens a Portal-based dialog. Suppresses the + // spurious blur-save fired when focus moves into the Portal. + const blurSuppressRef = useRef(false); + const lastSavedSigRef = useRef(''); + const autoSaveTimeoutRef = useRef | null>( + null, + ); + const AUTO_SAVE_DEBOUNCE_MS = 500; + + const performSave = useCallback(async () => { + if (isDraft) return; + if (blurSavingRef.current) return; + if (blurSuppressRef.current) return; + const isValid = await form.trigger(); + if (!isValid) return; + const values = form.getValues(); + const payload = buildPayload(values, instance.instance_name); + const finalPayload = { + ...payload, + id: instanceDetails?.id || instance.id, + }; + const sig = JSON.stringify(finalPayload); + if (sig === lastSavedSigRef.current) return; + blurSavingRef.current = true; + try { + const ret = await addProviderInstance(finalPayload as any); + if (ret?.code === 0) { + lastSavedSigRef.current = sig; + } + } finally { + blurSavingRef.current = false; + } + }, [ + isDraft, + form, + buildPayload, + instance.instance_name, + instance.id, + instanceDetails?.id, + addProviderInstance, + ]); + + const scheduleSave = useCallback(() => { + if (isDraft) return; + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + autoSaveTimeoutRef.current = setTimeout(() => { + autoSaveTimeoutRef.current = null; + void performSave(); + }, AUTO_SAVE_DEBOUNCE_MS); + }, [isDraft, performSave]); + + const handleFieldsBlur = useCallback( + (e: React.FocusEvent) => { + if (isDraft) return; + if ( + e.currentTarget.contains(e.relatedTarget as Node | null) && + e.relatedTarget !== null + ) { + return; + } + scheduleSave(); + }, + [isDraft, scheduleSave], + ); + + // Segmented / Select / MultiSelect change-driven save (saved mode + // only). These commit via click and their popovers render in portals, + // so blur-driven saves don't catch them. Watch the form directly. + // Only react to user-driven changes (type === 'change'); ignore + // programmatic resets (form.reset when instanceDetails loads). + useEffect(() => { + if (isDraft) return; + if (!instanceDetails) return; + let cancelled = false; + const subscription = form.watch( + (_values: any, meta: { name?: string; type?: string }) => { + if (cancelled) return; + if (meta?.type !== 'change') return; + if (!meta?.name || !BEDROCK_WATCHED_FIELDS.has(meta.name)) return; + scheduleSave(); + }, + ); + return () => { + cancelled = true; + try { + subscription?.unsubscribe?.(); + } catch { + // ignore + } + }; + }, [isDraft, instanceDetails, form, scheduleSave]); + + // Clear pending save on unmount. + useEffect(() => { + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + autoSaveTimeoutRef.current = null; + } + }; + }, []); + + const { deleteProviderInstance } = useDeleteProviderInstance(); + const handleDelete = useCallback(async () => { + if (isDraft) { + onDelete?.(); + } else { + await deleteProviderInstance({ + provider_name: providerName, + instances: [instance.instance_name], + }); + } + }, [ + isDraft, + providerName, + instance.instance_name, + deleteProviderInstance, + onDelete, + ]); + + // ──────────────── Field group rendered in both modes ──────────────── + const renderFields = () => ( +
    + e.preventDefault()}> + + {(field) => ( + + )} + + + + + + +
    + + {(field) => ( + { + if (value !== 'access_key_secret') { + form.setValue('bedrock_ak', ''); + form.setValue('bedrock_sk', ''); + } + if (value !== 'iam_role') { + form.setValue('aws_role_arn', ''); + } + field.onChange(value); + }} + options={[ + { + label: tSetting('awsAuthModeAccessKeySecret'), + value: 'access_key_secret', + }, + { label: tSetting('awsAuthModeIamRole'), value: 'iam_role' }, + { + label: tSetting('awsAuthModeAssumeRole'), + value: 'assume_role', + }, + ]} + /> + )} + +
    + + {authMode === 'access_key_secret' && ( + <> + + + + + + + + )} + + {authMode === 'iam_role' && ( + + + + )} + + {authMode === 'assume_role' && ( +
    + {tSetting('awsAssumeRoleTip')} +
    + )} + + + {(field) => ( + + )} + + + + {(field) => ( + field.onChange(Number(e.target.value))} + /> + )} + +
    + + {/* VerifyButton lives inside
    (FormProvider) so its + internal useFormContext() resolves the form instance. + Rendered outside so it never triggers submission. */} +
    + +
    +
    + ); + + return ( +
    + {nameSaved ? ( + + +
    +
    + + + {draftName || instance.instance_name} + +
    + + + +
    +
    + +
    + {renderFields()} + +
    + form.getValues()} + onBlurSuppressChange={(s) => { + blurSuppressRef.current = s; + }} + /> +
    +
    +
    +
    + ) : ( +
    +
    + +
    + setDraftName(e.target.value)} + placeholder={tSetting('instanceNamePlaceholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveName(); + } + }} + className="flex-1 rounded-r-none" + data-testid="instance-name-input" + /> + + + + +
    +

    + {tSetting('instanceNameSaveTip')} +

    +
    + +
    + {renderFields()} + +
    + form.getValues()} + /> +
    +
    +
    + )} +
    + ); +} + +export default BedrockInstanceCard; diff --git a/web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx new file mode 100644 index 0000000000..4ee6757d37 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/components/draft-mode-card.tsx @@ -0,0 +1,97 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DynamicForm, DynamicFormRef } from '@/components/dynamic-form'; +import { RefObject } from 'react'; +import { DRAFT_INSTANCE_SENTINEL, DraftModeCardProps } from '../interface'; +import { ModelsSection } from '../models-section'; +import VerifyButton from '../verify-button'; +import { InstanceNameSection } from './instance-name-section'; + +/** + * The draft (unsaved) variant of the provider instance card. + * + * Renders the instance name input section at the top, followed by a + * `
    ` that wraps the form fields, verify button, and + * per-instance models section. Fields are visually locked (and + * pointer-events disabled) until the user saves the instance name — + * after which the parent removes this draft and replaces it with a + * saved card. + */ +export function DraftModeCard({ + formFields, + formDefaultValues, + formRef, + handleVerify, + handleDelete, + handleSaveName, + handleInstanceModelsEdited, + providerName, + instanceName, + instance, + modelInfoRef, + draftName, + setDraftName, +}: DraftModeCardProps) { + return ( +
    + + +
    + } + fields={formFields} + onSubmit={() => undefined} + defaultValues={formDefaultValues} + labelClassName="font-normal" + /> + +
    + +
    + +
    + formRef.current?.getValues?.() ?? {}} + onInstanceModelsChange={(info) => { + modelInfoRef.current = info; + }} + onInstanceModelsEdited={handleInstanceModelsEdited} + /> +
    +
    +
    + ); +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx b/web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx new file mode 100644 index 0000000000..4c399c5d49 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/components/instance-name-section.tsx @@ -0,0 +1,99 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useTranslate } from '@/hooks/common-hooks'; +import { Trash2 } from 'lucide-react'; + +export interface InstanceNameSectionProps { + draftName: string; + setDraftName: (name: string) => void; + handleSaveName: () => Promise; + handleDelete: () => Promise; +} + +/** + * The instance-name input section shown at the top of a draft (unsaved) + * card. The input itself carries the destructive red border (no wrapping + * red box) and is paired with a Save button + delete icon. The helper + * text below explains the workflow. + */ +export function InstanceNameSection({ + draftName, + setDraftName, + handleSaveName, + handleDelete, +}: InstanceNameSectionProps) { + const { t: tSetting } = useTranslate('setting'); + + return ( +
    + +
    + setDraftName(e.target.value)} + placeholder={tSetting('instanceNamePlaceholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void handleSaveName(); + } + }} + // The input itself carries the red border (not a wrapping + // box). Persists while the name is unsaved. + className="flex-1 rounded-r-none" + data-testid="instance-name-input" + /> + + + + +
    +

    + {tSetting('instanceNameSaveTip')} +

    +
    + ); +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/components/saved-mode-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/components/saved-mode-card.tsx new file mode 100644 index 0000000000..d9c65566db --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/components/saved-mode-card.tsx @@ -0,0 +1,148 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; +import { DynamicForm, DynamicFormRef } from '@/components/dynamic-form'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { useTranslate } from '@/hooks/common-hooks'; +import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react'; +import { RefObject } from 'react'; +import { useTranslation } from 'react-i18next'; +import { DRAFT_INSTANCE_SENTINEL, SavedModeCardProps } from '../interface'; +import { ModelsSection } from '../models-section'; +import VerifyButton from '../verify-button'; + +/** + * The saved (non-draft) variant of the provider instance card. + * + * Renders a Collapsible whose trigger shows the instance name + delete + * button, and whose content holds the form fields, the verify button, + * and (when expanded) the per-instance models section. + */ +export function SavedModeCard({ + formFields, + formDefaultValues, + formRef, + handleFieldsBlur, + handleVerify, + handleDelete, + handleInstanceModelsEdited, + providerName, + instanceName, + instance, + instanceDetailsLoaded, + modelInfoRef, + blurSuppressRef, + draftName, + open, + setOpen, +}: SavedModeCardProps) { + const { t } = useTranslation(); + const { t: tSetting } = useTranslate('setting'); + + return ( + + +
    +
    + +
    + {draftName || instanceName} +
    +
    + + + +
    +
    + +
    + } + fields={formFields} + onSubmit={() => undefined} + defaultValues={formDefaultValues} + labelClassName="font-normal" + /> + +
    + +
    + + {open && ( +
    + formRef.current?.getValues?.() ?? {}} + onBlurSuppressChange={(s) => { + blurSuppressRef.current = s; + }} + onInstanceModelsChange={(info) => { + modelInfoRef.current = info; + }} + onInstanceModelsEdited={handleInstanceModelsEdited} + /> +
    + )} +
    +
    +
    + ); +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx b/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx new file mode 100644 index 0000000000..6950c48561 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/hooks.tsx @@ -0,0 +1,816 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DynamicFormRef, FormFieldType } from '@/components/dynamic-form'; +import { + useAddProviderInstance, + useDeleteProviderInstance, + useFetchAvailableProviders, + useFetchProviderInstance, + useUpdateProviderInstance, + useVerifyProviderConnection, +} from '@/hooks/use-llm-request'; +import { IProviderInstance } from '@/interfaces/database/llm'; +import { + IModelInfo, + IUpdateProviderInstanceRequestBody, +} from '@/interfaces/request/llm'; +import { RefObject, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useProviderFields } from '../provider-schema/hooks'; +import { SelectOption } from '../provider-schema/types'; +import { API_KEY_NESTED_FIELDS, ApiKeyNestedField } from './interface'; + +// --------------------------------------------------------------------------- +// Pure helpers — api_key shape normalization +// --------------------------------------------------------------------------- + +/** + * Build the `api_key` payload value from the flat form values. If any of + * the nested credential fields (see `API_KEY_NESTED_FIELDS`) carry a + * value, returns `{ api_key, ...nested }`; otherwise returns the plain + * api_key string. Used by both the auto-save payload and its change + * signature baseline so the two stay byte-identical. + */ +export function buildApiKeyValue( + values: Record, +): string | Record | undefined { + const nested: Record = {}; + for (const field of API_KEY_NESTED_FIELDS) { + const v = values[field]; + if (v !== undefined && v !== '') nested[field] = v; + } + if (Object.keys(nested).length === 0) return values.api_key; + return { api_key: values.api_key ?? '', ...nested }; +} + +/** + * Inverse of `buildApiKeyValue`, used on the echo/prefill path. The + * backend persists the credential bundle as a JSON string (see + * `rag/llm/chat_model.py`, which does `json.loads(key)`), so + * `showProviderInstance` may return `api_key` as: + * 1. a raw JSON string `'{"api_key":"sk-x","group_id":"123"}'` + * 2. an already-parsed object `{ api_key, group_id }` + * 3. a plain bare key string `'sk-x'` + * Normalise all three into the bare key plus any nested credential + * fields so the form pre-fills group_id / api_version / provider_order. + */ +export function unwrapApiKey(raw: unknown): { + apiKey: string; + nested: Record; +} { + let obj: any = raw; + if (typeof raw === 'string') { + const trimmed = raw.trim(); + if (trimmed.startsWith('{')) { + try { + obj = JSON.parse(trimmed); + } catch { + obj = raw; + } + } + } + if (obj && typeof obj === 'object') { + const nested: Record = {}; + for (const field of API_KEY_NESTED_FIELDS) { + const v = obj[field]; + if (v !== undefined && v !== '') nested[field] = v; + } + return { apiKey: obj.api_key ?? '', nested }; + } + return { apiKey: typeof raw === 'string' ? raw : '', nested: {} }; +} + +/** Pick the value associated with the `default` region, if present. */ +function pickDefaultUrl( + options?: Array<{ value: string; regionKey?: string }>, +): string | undefined { + return options?.find((o) => o.regionKey === 'default')?.value; +} + +// --------------------------------------------------------------------------- +// useProviderBaseUrlOptions — fetch provider catalog and build URL options +// --------------------------------------------------------------------------- + +/** + * Fetch the catalog of available providers and derive the + * `base_url` / `api_base` dropdown options for the current provider. + * Used to pre-fill the URL field with the provider's default URL when + * creating a new instance. + */ +export function useProviderBaseUrlOptions(providerName: string) { + const { data: availableProviders } = useFetchAvailableProviders(); + + const currentProvider = useMemo( + () => + providerName + ? availableProviders.find((p) => p.name === providerName) + : undefined, + [availableProviders, providerName], + ); + + const baseUrlOptions = useMemo(() => { + const urlObj = currentProvider?.url as + | Record + | undefined; + if (!urlObj) return undefined; + const options = Object.keys(urlObj) + .filter((k) => !!urlObj[k]) + .map((k) => { + const v = urlObj[k] as string; + return { + value: v, + regionKey: k, + label: ( +
    + {v} + + {k} + +
    + ), + }; + }); + return options.length > 0 ? options : undefined; + }, [currentProvider]); + + return { baseUrlOptions, availableProviders }; +} + +// --------------------------------------------------------------------------- +// useProviderInitialValues — form initial values for draft vs saved +// --------------------------------------------------------------------------- + +/** + * Build the form's initial values from the persisted instance, the + * lazy-loaded `showProviderInstance` details, and the provider's default + * base URL. + * + * - Draft: empty form, with `base_url` / `api_base` pre-filled from the + * provider's `default` URL when available. + * - Saved: prefer `instanceDetails` (which carries api_key / base_url); + * normalise api_key into the bare key plus any nested credential + * fields (see {@link unwrapApiKey}). + */ +export function useProviderInitialValues( + instance: IProviderInstance, + instanceDetails: IProviderInstance | undefined, + isDraft: boolean, + baseUrlOptions: SelectOption[] | undefined, +) { + return useMemo(() => { + const defaultBaseUrl = pickDefaultUrl(baseUrlOptions); + + if (isDraft) { + const values: Record = { instance_name: '' }; + if (defaultBaseUrl) { + values.base_url = defaultBaseUrl; + values.api_base = defaultBaseUrl; + } + return values; + } + + const merged: IProviderInstance = { + ...instance, + ...(instanceDetails ?? ({} as IProviderInstance)), + }; + const values: Record = { + instance_name: merged.instance_name, + }; + // api_key may come back as a JSON string, an already-parsed object, + // or a plain bare key (see `unwrapApiKey`). Normalise it so the + // api_key text field shows the bare key and the nested credential + // fields (MiniMax group_id, Azure api_version, OpenRouter + // provider_order) pre-fill their own form inputs. + if (merged.api_key) { + const { apiKey, nested } = unwrapApiKey(merged.api_key); + values.api_key = apiKey; + Object.assign(values, nested); + } + if (merged.base_url) { + values.base_url = merged.base_url; + values.api_base = merged.base_url; + } else if (defaultBaseUrl) { + values.base_url = defaultBaseUrl; + values.api_base = defaultBaseUrl; + } + // The /providers/

    /instances/ endpoint also returns `region` + // for providers where it applies; surface it so the form / region + // submit logic can echo it back. + if ((merged as any).region) values.region = (merged as any).region; + // Fallback: some backends may still surface these credential fields + // at the top level rather than nested in api_key — echo any that + // weren't already lifted from the api_key object above. + for (const field of API_KEY_NESTED_FIELDS) { + if (values[field] === undefined && (merged as any)[field] !== undefined) { + values[field] = (merged as any)[field]; + } + } + return values; + }, [instance, instanceDetails, isDraft, baseUrlOptions]); +} + +// --------------------------------------------------------------------------- +// useLazyInstanceDetails — fetch full instance details when card opens +// --------------------------------------------------------------------------- + +/** + * The list endpoint (`useFetchProviderInstances`) does not return + * sensitive/heavy fields like `api_key` or `base_url`. Pull the full + * instance via `showProviderInstance` so the form can be pre-filled when + * the user clicks an existing provider on the left. The hook is + * `enabled: false` by default — we trigger it manually here so we + * don't change behavior of other call sites. + */ +export function useLazyInstanceDetails( + providerName: string, + instanceName: string, + isDraft: boolean, + open: boolean, +) { + const { data: instanceDetails, refetch: refetchInstanceDetails } = + useFetchProviderInstance( + isDraft ? '' : providerName, + isDraft ? '' : instanceName, + ); + + useEffect(() => { + if (!isDraft && open && providerName && instanceName) { + refetchInstanceDetails(); + } + }, [isDraft, open, providerName, instanceName, refetchInstanceDetails]); + + return { instanceDetails, refetchInstanceDetails }; +} + +// --------------------------------------------------------------------------- +// useFormResetOnDetailsLoad — re-fill form when instanceDetails resolves +// --------------------------------------------------------------------------- + +/** + * React-Hook-Form only consumes `defaultValues` on first mount, so we + * explicitly reset the form here to make the freshly-fetched values + * visible. We use `keepDirtyValues` so the user's in-progress edits + * (if any) are not clobbered by a background refetch. + */ +export function useFormResetOnDetailsLoad( + formRef: RefObject, + formDefaultValues: Record, + instanceDetails: IProviderInstance | undefined, + isDraft: boolean, +) { + useEffect(() => { + if (isDraft) return; + if (!instanceDetails) return; + const form = (formRef.current as any)?.form; + if (form?.reset) { + form.reset(formDefaultValues, { keepDirtyValues: true }); + } else { + formRef.current?.reset?.(formDefaultValues); + } + }, [isDraft, instanceDetails, formDefaultValues, formRef]); +} + +// --------------------------------------------------------------------------- +// useVerifyProvider — wraps useVerifyProviderConnection for the card +// --------------------------------------------------------------------------- + +/** + * Adapter that reads the current form values and proxies them into + * `verifyProviderConnection`, then shapes the response into the + * `VerifyResult` consumed by {@link VerifyButton}. + */ +export function useVerifyProvider( + providerName: string, + formRef: RefObject, +) { + const { verifyProviderConnection } = useVerifyProviderConnection(); + + return useCallback( + async (params: any) => { + const values = { ...(formRef.current?.getValues?.() ?? {}), ...params }; + const ret = await verifyProviderConnection({ + provider_name: providerName, + api_key: values.api_key ?? '', + base_url: values.base_url ?? values.api_base, + model_info: values.model_info, + }); + if (ret.code === 0) { + return { isValid: true, logs: ret.message } as { + isValid: boolean; + logs: string; + }; + } + return { isValid: false, logs: ret.message } as { + isValid: boolean; + logs: string; + }; + }, + [providerName, formRef, verifyProviderConnection], + ); +} + +// --------------------------------------------------------------------------- +// useSaveInstanceName — dedicated hook for the draft name Save button +// --------------------------------------------------------------------------- + +/** + * Save the instance name on its own. Calls `addProviderInstance` with + * only the instance name (backend now supports creating an instance with + * just a name). On success notifies the parent via `onNameSaved` so it + * can remove this draft — the invalidated `providerInstances` query + * will surface the persisted card automatically. + */ +export function useSaveInstanceName( + providerName: string, + draftName: string, + onNameSaved?: () => void, +) { + const { addProviderInstance } = useAddProviderInstance(); + return useCallback(async () => { + const trimmed = draftName.trim(); + if (!trimmed) return; + const ret = await addProviderInstance({ + llm_factory: providerName, + instance_name: trimmed, + } as any); + if (ret?.code === 0) { + onNameSaved?.(); + } + }, [draftName, addProviderInstance, providerName, onNameSaved]); +} + +// --------------------------------------------------------------------------- +// useDeleteInstance — wires the card's delete button +// --------------------------------------------------------------------------- + +/** + * Delete handler: for saved instances calls `useDeleteProviderInstance`; + * for drafts calls `onDelete` (which maps to onCancel in the parent). + */ +export function useDeleteInstance( + providerName: string, + instanceName: string, + isDraft: boolean, + onDelete?: () => void, +) { + const { deleteProviderInstance } = useDeleteProviderInstance(); + return useCallback(async () => { + if (isDraft) { + onDelete?.(); + } else { + await deleteProviderInstance({ + provider_name: providerName, + instances: [instanceName], + }); + } + }, [isDraft, providerName, instanceName, deleteProviderInstance, onDelete]); +} + +// --------------------------------------------------------------------------- +// useDraftAutoSave — 200ms-debounced watch-based save for draft mode +// --------------------------------------------------------------------------- + +/** + * Auto-save: whenever the form's other fields change (in draft mode), + * watch the form values and, after a 200ms debounce (acting as a blur + * proxy — fires shortly after the user stops typing / blurs out of a + * field), trigger validation. If all required fields are valid AND + * the instance name has been entered and saved, call `onSaved` with + * the merged values. + */ +export function useDraftAutoSave( + formRef: RefObject, + isDraft: boolean, + nameSaved: boolean, + draftName: string, + onSaved: ((values: Record) => void | Promise) | undefined, + modelInfoRef: { current: IModelInfo[] }, +) { + // Keep the latest `onSaved` and `draftName` in refs so the auto-save + // effect below can read them without re-subscribing on every render + // (the parent passes a fresh `onSaved` arrow each render). + const onSavedRef = useRef(onSaved); + useEffect(() => { + onSavedRef.current = onSaved; + }); + const draftNameRef = useRef(draftName); + useEffect(() => { + draftNameRef.current = draftName; + }); + + useEffect(() => { + if (!isDraft) return; + + const formInstance = (formRef.current as any)?.form; + if (!formInstance || typeof formInstance.watch !== 'function') return; + + let saveTimeout: ReturnType | null = null; + let cancelled = false; + const savingRef = { current: false }; + + const subscription = formInstance.watch(() => { + if (saveTimeout) clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + if (cancelled || savingRef.current) return; + const isValid = await formRef.current?.trigger(); + if (cancelled || savingRef.current) return; + if (!isValid) return; + + // Name gate: refuse to actually save if the name is empty or + // has not been "saved" (locked). The red border on the name + // section is the visible signal — it stays on while + // `!nameSaved` regardless of whether the user has touched + // other fields. + if (!draftNameRef.current.trim() || !nameSaved) return; + if (!onSavedRef.current) return; + + savingRef.current = true; + try { + const values = formRef.current?.getValues?.() ?? {}; + const payload: Record = { + ...values, + instance_name: draftNameRef.current.trim(), + }; + // Forward the latest per-instance model list as `model_info` + // when the user has attached models to the draft. The list + // is normally empty for drafts (the backend has nothing yet), + // but it can be populated once ModelsSection has fetched / + // received data — we only set the key when non-empty so an + // absent value is preserved as `undefined` rather than + // forcing an empty-array clear on the server. + if (modelInfoRef.current.length > 0) { + payload.model_info = modelInfoRef.current; + } + await onSavedRef.current(payload); + } finally { + savingRef.current = false; + } + }, 200); + }); + + return () => { + cancelled = true; + if (saveTimeout) clearTimeout(saveTimeout); + try { + subscription?.unsubscribe?.(); + } catch { + // ignore cleanup errors + } + }; + }, [isDraft, nameSaved, formRef, modelInfoRef]); +} + +// --------------------------------------------------------------------------- +// useSavedAutoSave — blur + dropdown-driven auto-save for saved cards +// --------------------------------------------------------------------------- + +/** Field types whose value is committed via click/select (not blur). The + * card's `onBlurCapture` auto-save fires before the dropdown click + * handler commits the new value, and the popover content is rendered in + * a Radix portal outside the card's blur container, so blur-based saves + * are unreliable for these. We watch the form values directly and + * trigger the same auto-save on value change. */ +const DROPDOWN_FIELD_TYPES = new Set([ + FormFieldType.Select, + FormFieldType.MultiSelect, + FormFieldType.Segmented, + // `Custom` is the form-field type used by `inputSelect` in this + // codebase (see use-provider-fields). Every `Custom` field rendered + // inside the provider instance card is an `InputSelect` dropdown. + FormFieldType.Custom, +]); + +interface UseSavedAutoSaveArgs { + formRef: RefObject; + formFields: Array<{ name: string; type: FormFieldType; [k: string]: any }>; + providerName: string; + instanceName: string; + instanceId: string | undefined; + isDraft: boolean; + instanceDetails: IProviderInstance | undefined; + initialValues: Record; + modelInfoRef: { current: IModelInfo[] }; +} + +/** + * Wires the blur-driven + dropdown-driven auto-save flow used by saved + * (non-draft) cards. Returns a `handleFieldsBlur` for the card body to + * attach to `onBlurCapture`, plus a `markModelsEdited` callback for + * `onInstanceModelsEdited` so the next auto-save short-circuits after + * a PATCH-driven model change. + */ +export function useSavedAutoSave({ + formRef, + formFields, + providerName, + instanceName, + instanceId, + isDraft, + instanceDetails, + initialValues, + modelInfoRef, +}: UseSavedAutoSaveArgs) { + const { updateProviderInstance } = useUpdateProviderInstance(); + const blurSavingRef = useRef(false); + const blurSuppressRef = useRef(false); + const lastSavedPayloadRef = useRef(''); + const hasSyncedInstanceRef = useRef(false); + const autoSaveTimeoutRef = useRef | null>(null); + const AUTO_SAVE_DEBOUNCE_MS = 500; + + // Shared auto-save routine. Triggered by: + // - `handleFieldsBlur` (focus leaves a non-dropdown field), and + // - the dropdown value-change watcher (a dropdown field's value + // commits via click, not blur — and the popover is rendered in a + // Radix portal outside the card's blur container, so blur-based + // saves are unreliable for dropdowns). + const performAutoSave = useCallback(async () => { + if (isDraft) return; + if (blurSavingRef.current) return; + if (blurSuppressRef.current) return; + + const isValid = await formRef.current?.trigger(); + if (!isValid) return; + + const values = formRef.current?.getValues?.() ?? {}; + const resolvedId = instanceDetails?.id || instanceId; + // Providers like MiniMax / Azure-OpenAI / OpenRouter carry extra + // credential fields (group_id / api_version / provider_order) that + // the backend expects bundled *inside* api_key as an object rather + // than as top-level keys. Nesting them here also folds their values + // into the change signature below, so editing one actually triggers + // a blur-save. + const apiKeyValue = buildApiKeyValue(values as Record); + const payload: IUpdateProviderInstanceRequestBody = { + provider_name: providerName, + instance_name: instanceName, + id: resolvedId, + api_key: apiKeyValue ?? '', + base_url: values.base_url ?? values.api_base, + region: values.region || 'default', + model_info: [], + verify: false, + }; + // Pull the latest model list from ModelsSection (via the ref it + // updates). Only attach when non-empty so we don't accidentally + // wipe the persisted model set with an empty array. + if (modelInfoRef.current.length > 0) { + payload.model_info = modelInfoRef.current; + } + // Skip if nothing actually changed since the last save (or initial + // mount): prevents a no-op PUT on every focus shift. + const signature = JSON.stringify(payload); + if (signature === lastSavedPayloadRef.current) return; + + blurSavingRef.current = true; + try { + const ret = await updateProviderInstance(payload); + if (ret?.code === 0) { + lastSavedPayloadRef.current = signature; + hasSyncedInstanceRef.current = true; + } + } finally { + blurSavingRef.current = false; + } + }, [ + isDraft, + providerName, + instanceName, + instanceId, + instanceDetails?.id, + updateProviderInstance, + formRef, + modelInfoRef, + ]); + + // Debounced auto-save: coalesces rapid edits (blur cascade, + // successive dropdown changes, typing in filterable dropdowns) into + // a single delayed `performAutoSave` call. + const scheduleAutoSave = useCallback(() => { + if (isDraft) return; + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + autoSaveTimeoutRef.current = setTimeout(() => { + autoSaveTimeoutRef.current = null; + void performAutoSave(); + }, AUTO_SAVE_DEBOUNCE_MS); + }, [isDraft, performAutoSave]); + + const handleFieldsBlur = useCallback( + async (e: React.FocusEvent) => { + if (isDraft) return; + // Ignore focus moves that stay inside the same container. + if ( + e.currentTarget.contains(e.relatedTarget as Node | null) && + e.relatedTarget !== null + ) { + return; + } + scheduleAutoSave(); + }, + [isDraft, scheduleAutoSave], + ); + + // Refs so the dropdown watcher effect can invoke the latest callbacks + // without re-subscribing on every render (the parent passes a fresh + // `onBlurCapture` arrow each render, and `performAutoSave` changes + // whenever its deps change — e.g. when `instanceDetails` loads). + const performAutoSaveRef = useRef(performAutoSave); + useEffect(() => { + performAutoSaveRef.current = performAutoSave; + }); + const scheduleAutoSaveRef = useRef<() => void>(() => {}); + useEffect(() => { + scheduleAutoSaveRef.current = scheduleAutoSave; + }); + + // Clear any pending debounced save when the card unmounts so we don't + // fire a stale request after teardown. + useEffect(() => { + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + autoSaveTimeoutRef.current = null; + } + }; + }, []); + + // Seed the "last saved" signature once initial values are loaded so + // the first blur after mount doesn't trigger an unnecessary save. + useEffect(() => { + if (isDraft) return; + const resolvedId = instanceDetails?.id || instanceId; + if (!resolvedId) return; + // Match the api_key shape performAutoSave produces (extra credential + // fields nested inside api_key) so the first blur after mount + // doesn't see a signature diff and fire a redundant save. model_info + // is omitted for the same reason as in performAutoSave: model + // changes are owned by the per-model endpoints, not this auto-save. + const baseline = { + provider_name: providerName, + instance_name: instanceName, + id: resolvedId, + api_key: buildApiKeyValue(initialValues), + base_url: initialValues.base_url ?? initialValues.api_base, + region: initialValues.region, + model_info: [] as IModelInfo[], + }; + lastSavedPayloadRef.current = JSON.stringify(baseline); + }, [ + isDraft, + providerName, + instanceName, + instanceId, + instanceDetails?.id, + initialValues, + ]); + + // Dropdown value-change auto-save (saved mode only). A dropdown + // field's value commits via click, not blur — and the popover is + // rendered in a Radix portal outside the card's blur container, so + // blur-based saves are unreliable for dropdowns. + // + // We subscribe to the *raw* RHF form so we can read the change + // metadata `{ name, type }`. Only genuine user edits carry + // `type === 'change'`; programmatic updates (the `form.reset` that + // runs when `instanceDetails` loads, `setValue`, etc.) come through + // with an undefined type. Gating on `type === 'change'` is what stops + // merely opening the card / loading its details from firing a save — + // only an actual user selection in a dropdown schedules one. The + // shared debounce (`scheduleAutoSave`) coalesces it with any + // blur-triggered save. Skipped in draft mode (which has its own + // 200ms-debounced watch) and until `instanceDetails` loads. + const dropdownFieldNames = useMemo( + () => + formFields + .filter((f) => DROPDOWN_FIELD_TYPES.has(f.type)) + .map((f) => f.name), + [formFields], + ); + + useEffect(() => { + if (isDraft) return; + if (dropdownFieldNames.length === 0) return; + if (!instanceDetails) return; + const form = (formRef.current as any)?.form; + if (!form || typeof form.watch !== 'function') return; + + let cancelled = false; + const dropdownFieldSet = new Set(dropdownFieldNames); + + const subscription = form.watch( + (_values: any, meta: { name?: string; type?: string }) => { + if (cancelled) return; + // Ignore programmatic value changes (reset/setValue) — they have + // no `type`. Only react to user-driven dropdown selections. + if (meta?.type !== 'change') return; + if (!meta?.name || !dropdownFieldSet.has(meta.name)) return; + scheduleAutoSaveRef.current(); + }, + ); + + return () => { + cancelled = true; + try { + subscription?.unsubscribe?.(); + } catch { + // ignore cleanup errors + } + }; + }, [isDraft, dropdownFieldNames, instanceDetails, formRef]); + + // Absorb a model patch into the host's last-saved baseline. When the + // user saves the edit modal, patchInstanceModel has already persisted + // the new max_tokens / model_type / features server-side, so the next + // blur auto-save should NOT re-PUT the same model_info. By parsing + // the previously-saved payload and overwriting ONLY model_info, the + // baseline now matches the current state and the signature check in + // performAutoSave short-circuits — while any in-flight edits to + // api_key / base_url / region remain in `lastSavedPayloadRef` + // unchanged and will still trigger a save on blur via signature + // mismatch. + // + // Skipped until the host has synced at least once. Before that the + // baseline still carries the initial `model_info: []`; rewriting it + // here would skip the very first PUT that syncs the user's first + // add/edit into the persisted model_info. + const markModelsEdited = useCallback(() => { + if (isDraft) return; + if (!hasSyncedInstanceRef.current) return; + const prev = lastSavedPayloadRef.current; + if (!prev) return; + const parsed = JSON.parse(prev) as IUpdateProviderInstanceRequestBody; + parsed.model_info = + modelInfoRef.current.length > 0 ? modelInfoRef.current : []; + // Mirror the `verify: false` field that performAutoSave always + // attaches, otherwise the next signature comparison would diff on + // this key alone and re-fire the save. + (parsed as any).verify = false; + lastSavedPayloadRef.current = JSON.stringify(parsed); + }, [isDraft, modelInfoRef]); + + return { + handleFieldsBlur, + performAutoSave, + scheduleAutoSave, + blurSuppressRef, + markModelsEdited, + }; +} + +// --------------------------------------------------------------------------- +// useFormFields — wraps useProviderFields and strips instance_name +// --------------------------------------------------------------------------- + +/** + * Wraps `useProviderFields` and removes the `instance_name` field from + * both the field list and the default values — the card header owns + * the instance name (editable on hover), so we keep a single source of + * truth and avoid showing it twice in the form. + */ +export function useFormFields( + providerName: string, + isDraft: boolean, + initialValues: Record, + baseUrlOptions: SelectOption[] | undefined, + hideWhenInstanceExists: (values: any) => boolean, +) { + const { fields, defaultValues } = useProviderFields({ + llmFactory: providerName, + editMode: !isDraft, + viewMode: isDraft, + initialValues, + baseUrlOptions, + hideWhenInstanceExists, + }); + + const formFields = useMemo( + () => fields.filter((f) => f.name !== 'instance_name'), + [fields], + ); + const formDefaultValues = useMemo(() => { + const { instance_name: _ignored, ...rest } = (defaultValues ?? + {}) as Record; + void _ignored; + return rest; + }, [defaultValues]); + + return { formFields, formDefaultValues }; +} + +// Re-export ApiKeyNestedField for components that need it. +export type { ApiKeyNestedField }; diff --git a/web/src/pages/user-setting/setting-model/instance-card/interface.ts b/web/src/pages/user-setting/setting-model/instance-card/interface.ts new file mode 100644 index 0000000000..c8e2de584e --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/interface.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DynamicFormRef, FormFieldConfig } from '@/components/dynamic-form'; +import { IProviderInstance } from '@/interfaces/database/llm'; +import { IModelInfo } from '@/interfaces/request/llm'; +import { RefObject } from 'react'; + +/** Public props for {@link ProviderInstanceCard}. */ +export interface ProviderInstanceCardProps { + providerName: string; + /** + * The instance to render. When `isDraft` is true, this is a placeholder + * used to render the "new instance" inline form; the actual save call + * will use the values typed in the form fields. + */ + instance: IProviderInstance; + /** + * True when this card represents a freshly-added (unsaved) instance. + * Renders Save / Cancel buttons and treats all fields as editable. + */ + isDraft?: boolean; + /** Called after a draft instance is successfully saved. */ + onSaved?: (values: Record) => void | Promise; + /** + * Called after a draft instance's *name* has been persisted via + * `addProviderInstance` (with just `instance_name`). The parent should + * remove this draft from its visible list; the freshly invalidated + * `providerInstances` query will surface the persisted card. + */ + onNameSaved?: () => void; + /** + * Called when the user deletes a draft instance. + * For drafts this is equivalent to onCancel; for saved instances + * the component calls useDeleteProviderInstance internally. + */ + onDelete?: () => void; + /** + * When true, this card starts expanded and its instance details + * are fetched on mount. Default `false` so additional cards stay + * collapsed until the user opens them — at which point details + * are fetched on demand. + */ + defaultOpen?: boolean; +} + +/** + * Provider-specific credential fields that the backend expects bundled + * *inside* `api_key` as an object rather than as top-level keys: + * api_key: { api_key, group_id?, api_version?, provider_order? } + * - MiniMax → group_id + * - Azure OpenAI → api_version + * - OpenRouter → provider_order + * When none of these are present the api_key stays a bare string. + */ +export const API_KEY_NESTED_FIELDS = [ + 'group_id', + 'api_version', + 'provider_order', +] as const; + +export type ApiKeyNestedField = (typeof API_KEY_NESTED_FIELDS)[number]; + +/** Sentinel instance name used by draft (unsaved) provider cards. */ +export const DRAFT_INSTANCE_SENTINEL = '__draft__'; + +// --------------------------------------------------------------------------- +// Sub-component props +// --------------------------------------------------------------------------- + +/** Props for the saved-mode (collapsible) card body. */ +export interface SavedModeCardProps { + formFields: FormFieldConfig[]; + formDefaultValues: Record; + formRef: RefObject; + handleFieldsBlur: (e: React.FocusEvent) => void; + handleVerify: (params: any) => Promise<{ isValid: boolean; logs: string }>; + handleDelete: () => Promise; + handleInstanceModelsEdited: () => void; + providerName: string; + instanceName: string; + instance: IProviderInstance; + instanceDetailsLoaded: boolean; + modelInfoRef: React.MutableRefObject; + blurSuppressRef: React.MutableRefObject; + draftName: string; + open: boolean; + setOpen: (open: boolean) => void; +} + +/** Props for the draft-mode card (instance name + locked form fields). */ +export interface DraftModeCardProps { + formFields: FormFieldConfig[]; + formDefaultValues: Record; + formRef: RefObject; + handleVerify: (params: any) => Promise<{ isValid: boolean; logs: string }>; + handleDelete: () => Promise; + handleSaveName: () => Promise; + handleInstanceModelsEdited: () => void; + providerName: string; + instanceName: string; + instance: IProviderInstance; + modelInfoRef: React.MutableRefObject; + draftName: string; + setDraftName: (name: string) => void; +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx new file mode 100644 index 0000000000..65617b887a --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-row.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Minus, Plus } from 'lucide-react'; +import { ModelRowProps } from '../interface'; +import { ModelTypeBadges } from './model-type-badges'; +import { ModelVerifyButton } from './model-verify-button'; + +/** Single model row in the catalog list. */ +export function ModelRow({ + model, + isAdded, + verifyStatus, + hideActions, + onVerify, + onAdd, + onRemove, + onEdit, + editLabel, +}: ModelRowProps) { + return ( +

  • +
    +
    + + {model.name} + +
    +
    + +
    +
    + +
    + + + {!hideActions && ( + + )} +
    +
  • + ); +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-type-badges.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-type-badges.tsx new file mode 100644 index 0000000000..f8d38d8701 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-type-badges.tsx @@ -0,0 +1,90 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Pencil } from 'lucide-react'; +import { mapModelKey } from '../../available-models'; +import { ModelTypeBadgesProps } from '../interface'; + +/** Max model-type badges shown inline; the rest collapse into a tooltip. */ +const MAX_VISIBLE_TYPES = 3; + +/** Renders the model-type badges row, collapsing overflow into a tooltip. */ +export function ModelTypeBadges({ + types, + onEdit, + showEdit, + editLabel, + editTestSuffix, +}: ModelTypeBadgesProps) { + const visible = types.slice(0, MAX_VISIBLE_TYPES); + const hidden = types.slice(MAX_VISIBLE_TYPES); + return ( + <> + {visible.map((mt) => ( + + {mapModelKey[mt as keyof typeof mapModelKey] || mt} + + ))} + {hidden.length > 0 && ( + + + + +{hidden.length} + + + +
    + {hidden.map((mt) => ( + + {mapModelKey[mt as keyof typeof mapModelKey] || mt} + + ))} +
    +
    +
    + )} + {showEdit && ( + + )} + + ); +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-verify-button.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-verify-button.tsx new file mode 100644 index 0000000000..81e3655680 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/model-verify-button.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cn } from '@/lib/utils'; +import { Check, Loader2, RefreshCcw, TriangleAlert } from 'lucide-react'; +import { ModelVerifyButtonProps, VerifyStatus } from '../interface'; + +/** Icon + color for a single verify status. */ +const VERIFY_ICON: Record< + VerifyStatus, + { icon: React.ReactNode; className: string } +> = { + idle: { + icon: , + className: 'text-text-secondary hover:bg-bg-input hover:text-text-primary', + }, + loading: { + icon: , + className: 'text-text-secondary cursor-wait', + }, + success: { + icon: , + className: 'text-state-success', + }, + error: { + icon: , + className: 'text-state-warning', + }, +}; + +/** Per-model verify button. */ +export function ModelVerifyButton({ + status, + onVerify, + modelName, +}: ModelVerifyButtonProps) { + const cfg = VERIFY_ICON[status]; + return ( + + ); +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/components/tag-filter-button.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/tag-filter-button.tsx new file mode 100644 index 0000000000..eff601453d --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/components/tag-filter-button.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { cn } from '@/lib/utils'; +import { TagFilterButtonProps } from '../interface'; + +/** Tag pill used in the filter row. */ +export function TagFilterButton({ + label, + count, + active, + onClick, +}: TagFilterButtonProps) { + return ( + + ); +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts new file mode 100644 index 0000000000..7f763cf5da --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/hooks.ts @@ -0,0 +1,601 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + useAddInstanceModel, + useDeleteInstanceModels, + useListProviderModels, + usePatchInstanceModel, + useUpdateProviderInstance, + useVerifyProviderConnection, +} from '@/hooks/use-llm-request'; +import { IInstanceModel, IProviderInstance } from '@/interfaces/database/llm'; +import { IModelInfo, IProviderModelItem } from '@/interfaces/request/llm'; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { sortModelTypes } from '../available-models'; +import { useCustomModelFields } from '../use-custom-model-fields'; +import { ModelsSectionProps, VerifyStatus } from './interface'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Feature keys that mark a model as supporting tool/function calls. */ +const TOOL_FEATURE_KEYS = ['is_tools', 'tool_call', 'tools', 'function_call']; + +/** Sentinel instance name used by draft (unsaved) provider cards. */ +export const DRAFT_INSTANCE_SENTINEL = '__draft__'; + +// --------------------------------------------------------------------------- +// Pure helpers (no React state, easy to test) +// --------------------------------------------------------------------------- + +/** True when `features` contains any of {@link TOOL_FEATURE_KEYS}. */ +export const hasToolFeature = ( + features: string[] | null | undefined, +): boolean => + Array.isArray(features) && + features.some((f) => TOOL_FEATURE_KEYS.includes(f)); + +/** + * Normalize the assorted shapes returned by the backend for a model's + * `model_types` into a plain `string[]`. + * - already an array → as-is + * - a single string → wrapped + * - nullish / other → [] + */ +export const normalizeModelTypes = (raw: unknown): string[] => + Array.isArray(raw) ? raw : raw ? [raw as string] : []; + +/** + * Build an `IModelInfo[]` (the shape the PUT + * `/providers/{name}/instances/{name}` endpoint expects) from a list of + * provider model items. `features` is forwarded via `extra` so the backend + * can persist per-model flags such as `is_tools`. + */ +export const buildModelInfo = (items: IProviderModelItem[]): IModelInfo[] => + items.map((m) => ({ + model_name: m.name, + model_type: m.model_types ?? [], + max_tokens: m.max_tokens ?? 0, + extra: { is_tools: hasToolFeature(m.features) }, + })); + +/** Resolved credentials for catalog / verify / batch calls. */ +export type ResolvedCreds = { apiKey: string; baseUrl: string }; + +// --------------------------------------------------------------------------- +// 1. useResolveCreds — resolve api_key / base_url from host form or instance +// --------------------------------------------------------------------------- + +export function useResolveCreds( + instance: IProviderInstance | undefined, + getFormValues: ModelsSectionProps['getFormValues'], +) { + // Prefer the live values from the host card's form (so the user can + // verify with an api_key they have just typed but not yet saved); fall + // back to the persisted instance fields when no form getter is wired up. + const resolveCreds = useCallback((): ResolvedCreds => { + const fv = getFormValues?.() ?? {}; + return { + apiKey: (fv.api_key as string) ?? instance?.api_key ?? '', + baseUrl: + (fv.base_url as string) ?? + (fv.api_base as string) ?? + instance?.base_url ?? + '', + }; + }, [getFormValues, instance]); + + return { resolveCreds }; +} + +// --------------------------------------------------------------------------- +// 2. useModelsCatalog — upstream provider catalog fetch + auto-fetch +// --------------------------------------------------------------------------- + +interface UseModelsCatalogArgs { + providerName: string; + instanceName: string; + hideActions: boolean; + isDraftInstance: boolean; + resolveCreds: () => ResolvedCreds; + instanceModels: IInstanceModel[] | undefined; +} + +export function useModelsCatalog({ + providerName, + instanceName, + hideActions, + isDraftInstance, + resolveCreds, + instanceModels, +}: UseModelsCatalogArgs) { + const { listProviderModels } = useListProviderModels(); + const [catalog, setCatalog] = useState([]); + const [manualListLoading, setManualListLoading] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + + // Manual "List models" handler — hits the upstream catalog endpoint. + // The result is merged into `catalog`; the displayed list then becomes + // the union of catalog + instance models. + const handleListModels = async () => { + setManualListLoading(true); + try { + const { apiKey, baseUrl } = resolveCreds(); + const ret = await listProviderModels({ + provider_name: providerName, + api_key: apiKey, + base_url: baseUrl, + }); + if (ret?.code === 0) { + setCatalog((ret.data as IProviderModelItem[]) ?? []); + } + setHasFetched(true); + } catch { + setHasFetched(true); + } finally { + setManualListLoading(false); + } + }; + + // Auto-fetch the provider's available-models catalog when this section + // mounts (effectively "when the card is expanded"). Skipped for draft + // instances and catalog-preview-only hosts. + const hasAutoFetchedRef = useRef(false); + useEffect(() => { + if (hasAutoFetchedRef.current) return; + if (hideActions) return; + if (!providerName) return; + if (isDraftInstance) return; + hasAutoFetchedRef.current = true; + handleListModels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [providerName, instanceName, hideActions]); + + // Mark `hasFetched` true once the per-instance query resolves — even if + // it returned an empty array — so `hideIfEmpty` can safely take effect. + useEffect(() => { + if (instanceModels) { + setHasFetched(true); + } + }, [instanceModels]); + + return { + catalog, + setCatalog, + manualListLoading, + hasFetched, + handleListModels, + }; +} + +// --------------------------------------------------------------------------- +// 3. useModelsDerived — derived model list (instance ∪ catalog) + sync +// --------------------------------------------------------------------------- + +interface UseModelsDerivedArgs { + catalog: IProviderModelItem[]; + instanceModels: IInstanceModel[] | undefined; + onInstanceModelsChange: ModelsSectionProps['onInstanceModelsChange']; + onInstanceModelsEdited?: ModelsSectionProps['onInstanceModelsEdited']; +} + +export function useModelsDerived({ + catalog, + instanceModels, + onInstanceModelsChange, + onInstanceModelsEdited, +}: UseModelsDerivedArgs) { + const catalogFeatures = useMemo(() => { + const map = new Map(); + catalog.forEach((m) => { + if (Array.isArray(m.features) && m.features.length > 0) { + map.set(m.name, m.features); + } + }); + return map; + }, [catalog]); + + const instanceItems: IProviderModelItem[] = useMemo(() => { + // `im` is typed `any` because the backend may return either + // `model_type` or `model_types`, and `features` is not on the + // declared IInstanceModel interface. + return (instanceModels ?? []).map((im: any) => { + const model_types = normalizeModelTypes( + im.model_types ?? im.model_type ?? [], + ); + const catalogFeats = catalogFeatures.get(im.name) ?? im.features ?? null; + const features = + im.is_tools && !hasToolFeature(catalogFeats) + ? [...(catalogFeats ?? []), 'is_tools'] + : catalogFeats; + return { + name: im.name, + max_tokens: im.max_tokens ?? 0, + model_types, + features, + }; + }); + }, [instanceModels, catalogFeatures]); + + // Union of instance models + catalog, keyed by `name`. Catalog entries + // win on conflict; instance set listed first so already-added models + // stay at the top on the initial render. + const models: IProviderModelItem[] = useMemo(() => { + const byName = new Map(); + instanceItems.forEach((m) => byName.set(m.name, m)); + catalog.forEach((m) => byName.set(m.name, m)); + return Array.from(byName.values()); + }, [instanceItems, catalog]); + + const addedSet = useMemo( + () => new Set((instanceModels ?? []).map((m: IInstanceModel) => m.name)), + [instanceModels], + ); + + // Keep the latest callbacks in refs so the effect below only fires + // when `instanceItems` actually changes — not on every parent + // re-render that passes a new arrow for the callbacks. The previous + // deps included the callbacks directly, which made the effect re-run + // with the same data on every render; that was harmless for the + // idempotent model_info push, but the new "edited" callback updates + // the host's last-saved baseline and must not absorb in-flight form + // edits fired by an unrelated re-render. + const onChangeRef = useRef(onInstanceModelsChange); + const onEditedRef = useRef(onInstanceModelsEdited); + useEffect(() => { + onChangeRef.current = onInstanceModelsChange; + onEditedRef.current = onInstanceModelsEdited; + }); + + // Track the previous set of instance model names so we can tell + // "patch" (same names, different data) apart from "add/remove" + // (different names). Only the patch case needs to fire the host-side + // baseline-update callback so the next blur auto-save short-circuits. + const prevNamesRef = useRef>(new Set()); + + // Push the latest per-instance model list up to the host so its + // auto-save can include `model_info` in the payload. When the change + // is purely a patch (same names, different data), also notify the + // host via `onInstanceModelsEdited` so it can absorb the model_info + // diff into its last-saved baseline — without this signal, the next + // blur would signature-mismatch and fire a redundant PUT carrying + // the already-PATCH-saved model_info. Adds/removes intentionally + // skip this signal so the next blur still carries the updated list + // into PUT (the standard sync path for the instance's model_info). + useEffect(() => { + const currentNames = new Set(instanceItems.map((m) => m.name)); + const prevNames = prevNamesRef.current; + const isPatch = + currentNames.size > 0 && + currentNames.size === prevNames.size && + Array.from(currentNames).every((n) => prevNames.has(n)); + + onChangeRef.current?.(buildModelInfo(instanceItems)); + if (isPatch) { + onEditedRef.current?.(); + } + + prevNamesRef.current = currentNames; + }, [instanceItems]); + + return { instanceItems, models, addedSet }; +} + +// --------------------------------------------------------------------------- +// 4. useModelsFilter — search box + tag filter +// --------------------------------------------------------------------------- + +export function useModelsFilter(models: IProviderModelItem[]) { + const [search, setSearch] = useState(''); + const [tag, setTag] = useState(null); + + const filteredModels = useMemo(() => { + const q = search.trim().toLowerCase(); + return models.filter((m) => { + if (q && !m.name.toLowerCase().includes(q)) return false; + if (tag && !m.model_types?.includes(tag)) return false; + return true; + }); + }, [models, search, tag]); + + const allTags = useMemo(() => { + const tagsSet = new Set(); + models.forEach((m) => m.model_types?.forEach((t) => tagsSet.add(t))); + return sortModelTypes(Array.from(tagsSet)); + }, [models]); + + return { search, tag, setSearch, setTag, filteredModels, allTags }; +} + +// --------------------------------------------------------------------------- +// 5. useModelVerify — per-model verify state + handler +// --------------------------------------------------------------------------- + +interface UseModelVerifyArgs { + providerName: string; + resolveCreds: () => ResolvedCreds; + instanceModels: IInstanceModel[] | undefined; +} + +export function useModelVerify({ + providerName, + resolveCreds, + instanceModels, +}: UseModelVerifyArgs) { + const { verifyProviderConnection } = useVerifyProviderConnection(); + const [verify, setVerify] = useState>({}); + + // Seed the per-model verify status from the backend's persisted `verify` + // flag on each instance model. + useEffect(() => { + if (!instanceModels || instanceModels.length === 0) return; + setVerify((prev) => { + let changed = false; + const next = { ...prev }; + for (const im of instanceModels) { + if (im.name in next) continue; + if (im.verify === 'success') { + next[im.name] = 'success'; + changed = true; + } else if (im.verify === 'fail') { + next[im.name] = 'error'; + changed = true; + } + } + return changed ? next : prev; + }); + }, [instanceModels]); + + const handleVerify = async (model: IProviderModelItem) => { + setVerify((prev) => ({ ...prev, [model.name]: 'loading' })); + try { + const { apiKey, baseUrl } = resolveCreds(); + const ret = await verifyProviderConnection({ + provider_name: providerName, + api_key: apiKey, + base_url: baseUrl, + model_info: [ + { + model_name: model.name, + model_type: model.model_types ?? [], + max_tokens: model.max_tokens ?? 0, + }, + ], + }); + setVerify((prev) => ({ + ...prev, + [model.name]: ret.code === 0 ? 'success' : 'error', + })); + } catch { + setVerify((prev) => ({ ...prev, [model.name]: 'error' })); + } + }; + + return { verify, handleVerify }; +} + +// --------------------------------------------------------------------------- +// 6. useModelMutations — add / remove / batch toggle / custom add +// --------------------------------------------------------------------------- + +interface UseModelMutationsArgs { + providerName: string; + instanceName: string; + isDraftInstance: boolean; + hideActions: boolean; + resolveCreds: () => ResolvedCreds; + instance: IProviderInstance | undefined; + instanceItems: IProviderModelItem[]; + filteredModels: IProviderModelItem[]; + addedSet: Set; + setCatalog: Dispatch>; +} + +export function useModelMutations({ + providerName, + instanceName, + isDraftInstance, + hideActions, + resolveCreds, + instance, + instanceItems, + filteredModels, + addedSet, + setCatalog, +}: UseModelMutationsArgs) { + const { addInstanceModel } = useAddInstanceModel(); + const { deleteInstanceModels } = useDeleteInstanceModels(); + const { updateProviderInstance, loading: batchLoading } = + useUpdateProviderInstance(); + + // True when every model currently shown in the filtered list is already + // attached to the instance — drives the +/- toggle on the batch button. + const allFilteredAdded = useMemo( + () => + filteredModels.length > 0 && + filteredModels.every((m) => addedSet.has(m.name)), + [filteredModels, addedSet], + ); + + const handleAddModel = async (model: IProviderModelItem) => { + await addInstanceModel({ + provider_name: providerName, + instance_name: instanceName, + model_name: model.name, + model_type: model.model_types ?? [], + max_tokens: model.max_tokens ?? 0, + extra: { is_tools: hasToolFeature(model.features) }, + }); + }; + + const handleRemoveModel = async (model: IProviderModelItem) => { + await deleteInstanceModels({ + provider_name: providerName, + instance_name: instanceName, + model_name: [model.name], + }); + }; + + const handleAddCustom = async (item: IProviderModelItem) => { + // Append the custom item to the local catalog so it shows up in the + // unioned `models` list immediately. Server-side persistence happens + // via `addInstanceModel` below (when there is a real instance). + setCatalog((prev) => + prev.some((m) => m.name === item.name) ? prev : [...prev, item], + ); + if (hideActions || isDraftInstance) { + return; + } + await addInstanceModel({ + provider_name: providerName, + instance_name: instanceName, + model_name: item.name, + model_type: item.model_types ?? [], + max_tokens: item.max_tokens ?? 0, + extra: { is_tools: hasToolFeature(item.features) }, + }); + }; + + // Batch attach/detach the currently visible (filtered) models via the + // PUT `/providers/{name}/instances/{name}` endpoint (replaces + // `model_info` wholesale). + const handleBatchToggleModels = async () => { + if (filteredModels.length === 0) return; + const { apiKey, baseUrl } = resolveCreds(); + + const byName = new Map(); + instanceItems.forEach((m) => byName.set(m.name, m)); + + let nextModels: IProviderModelItem[]; + if (allFilteredAdded) { + const drop = new Set(filteredModels.map((m) => m.name)); + nextModels = Array.from(byName.values()).filter((m) => !drop.has(m.name)); + } else { + filteredModels.forEach((m) => byName.set(m.name, m)); + nextModels = Array.from(byName.values()); + } + + await updateProviderInstance({ + provider_name: providerName, + instance_name: instanceName, + api_key: apiKey, + base_url: baseUrl, + region: instance?.region ?? 'default', + model_info: buildModelInfo(nextModels), + }); + }; + + return { + allFilteredAdded, + handleAddModel, + handleRemoveModel, + handleAddCustom, + handleBatchToggleModels, + batchLoading, + }; +} + +// --------------------------------------------------------------------------- +// 7. useModelEdit — edit dialog state + submit +// --------------------------------------------------------------------------- + +interface UseModelEditArgs { + providerName: string; + instanceName: string; + setCatalog: Dispatch>; +} + +export function useModelEdit({ + providerName, + instanceName, + setCatalog, +}: UseModelEditArgs) { + const customModelDialogFields = useCustomModelFields(); + const { patchInstanceModel, loading: editLoading } = usePatchInstanceModel(); + // Model currently being edited via AddCustomModelDialog (with `name` + // pinned/disabled and the dialog initial values pre-populated from the + // model's current config). `null` when the edit dialog is closed. + const [editingModel, setEditingModel] = useState( + null, + ); + + // Field schema for the edit dialog — identical to the add schema + // except the `name` field is locked (model name is the row's primary + // key and the API forbids renaming via this endpoint). + const editModelDialogFields = useMemo( + () => + customModelDialogFields.map((f) => + f.name === 'name' ? { ...f, disabled: true } : f, + ), + [customModelDialogFields], + ); + + // Initial form values for the edit dialog, derived from the model + // currently being edited. + const editDefaultValues = useMemo(() => { + if (!editingModel) return undefined; + return { + name: editingModel.name, + model_types: editingModel.model_types ?? [], + max_tokens: editingModel.max_tokens ?? 0, + features: editingModel.features ?? [], + }; + }, [editingModel]); + + // Persist edits to an existing model. The local `catalog` is patched so + // the UI reflects the new values immediately, before the cache + // invalidation lands. + const handleEditSubmit = async (item: IProviderModelItem) => { + if (!editingModel) return; + const targetName = editingModel.name; + + setCatalog((prev) => + prev.some((m) => m.name === targetName) + ? prev.map((m) => (m.name === targetName ? item : m)) + : prev, + ); + + await patchInstanceModel({ + provider_name: providerName, + instance_name: instanceName, + model_name: targetName, + max_tokens: item.max_tokens ?? 0, + model_type: item.model_types ?? [], + extra: { is_tools: hasToolFeature(item.features) }, + }); + setEditingModel(null); + }; + + return { + editingModel, + setEditingModel, + editModelDialogFields, + editDefaultValues, + handleEditSubmit, + editLoading, + customModelDialogFields, + }; +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx b/web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx new file mode 100644 index 0000000000..2feb418e59 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/index.tsx @@ -0,0 +1,311 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Button } from '@/components/ui/button'; +import { SearchInput } from '@/components/ui/input'; +import { useCommonTranslation, useTranslate } from '@/hooks/common-hooks'; +import { useFetchInstanceModels } from '@/hooks/use-llm-request'; +import { ListMinus, ListPlus, Loader2, Plus, Search } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AddCustomModelDialog } from '../add-custom-model-dialog'; +import { mapModelKey } from '../available-models'; +import { ModelRow } from './components/model-row'; +import { TagFilterButton } from './components/tag-filter-button'; +import { + DRAFT_INSTANCE_SENTINEL, + useModelEdit, + useModelMutations, + useModelVerify, + useModelsCatalog, + useModelsDerived, + useModelsFilter, + useResolveCreds, +} from './hooks'; +import { ModelsSectionProps } from './interface'; + +export function ModelsSection(props: ModelsSectionProps) { + const { t } = useTranslation(); + const { t: tSetting } = useTranslate('setting'); + const { t: tc } = useCommonTranslation(); + + const { + providerName, + instanceName, + instance, + hideActions = false, + hideIfEmpty = false, + getFormValues, + onBlurSuppressChange, + onInstanceModelsChange, + onInstanceModelsEdited, + } = props; + + const isDraftInstance = + !instanceName || instanceName === DRAFT_INSTANCE_SENTINEL; + + // 1. Credentials for catalog / verify / batch calls. + const { resolveCreds } = useResolveCreds(instance, getFormValues); + + // 2. Per-instance saved models (shared by catalog, derived, verify). + const { data: instanceModels } = useFetchInstanceModels( + providerName, + instanceName, + ); + + // 3. Upstream catalog + auto-fetch on mount. + const { + catalog, + setCatalog, + manualListLoading, + hasFetched, + handleListModels, + } = useModelsCatalog({ + providerName, + instanceName, + hideActions, + isDraftInstance, + resolveCreds, + instanceModels, + }); + + // 4. Derived union list (instance ∪ catalog) + push to host. + const { instanceItems, models, addedSet } = useModelsDerived({ + catalog, + instanceModels, + onInstanceModelsChange, + onInstanceModelsEdited, + }); + + // 5. Search + tag filter. + const { search, tag, setSearch, setTag, filteredModels, allTags } = + useModelsFilter(models); + + // 6. Per-model verify state. + const { verify, handleVerify } = useModelVerify({ + providerName, + resolveCreds, + instanceModels, + }); + + // 7. Add / remove / batch toggle / custom add. + const { + allFilteredAdded, + handleAddModel, + handleRemoveModel, + handleAddCustom, + handleBatchToggleModels, + batchLoading, + } = useModelMutations({ + providerName, + instanceName, + isDraftInstance, + hideActions, + resolveCreds, + instance, + instanceItems, + filteredModels, + addedSet, + setCatalog, + }); + + // 8. Edit dialog state + submit. + const { + editingModel, + setEditingModel, + editModelDialogFields, + editDefaultValues, + handleEditSubmit, + editLoading, + customModelDialogFields, + } = useModelEdit({ + providerName, + instanceName, + setCatalog, + }); + + // Add-custom-model dialog open state (local UI state). + const [dialogOpen, setDialogOpen] = useState(false); + + // Mirror dialog open state up to the host so it can pause its + // blur-driven auto-save while the dialog is open (focus shifts into a + // React Portal outside the host's onBlurCapture container). + useEffect(() => { + const open = dialogOpen || editingModel !== null; + onBlurSuppressChange?.(open); + return () => { + if (open) onBlurSuppressChange?.(false); + }; + }, [dialogOpen, editingModel, onBlurSuppressChange]); + + // hideIfEmpty: render nothing once the first fetch completes with no models. + if (hideIfEmpty && hasFetched && models.length === 0) { + return null; + } + + return ( +
    +
    +
    + {t('setting.models')} +
    + {!hideActions && ( +
    + + +
    + )} +
    + +
    +
    +
    + setSearch(e.target.value)} + placeholder={t('setting.search')} + rootClassName="flex-1" + /> + {!hideActions && ( + + )} +
    +
    + setTag(null)} + /> + {allTags.map((tKey) => ( + m.model_types?.includes(tKey)).length + } + active={tag === tKey} + onClick={() => setTag(tag === tKey ? null : tKey)} + /> + ))} +
    +
    + +
    + {filteredModels.length === 0 ? ( +
    + + {t('setting.listModelsEmpty')} +
    + ) : ( +
      + {filteredModels.map((model) => ( + handleVerify(model)} + onAdd={() => handleAddModel(model)} + onRemove={() => handleRemoveModel(model)} + onEdit={() => setEditingModel(model)} + editLabel={tSetting('editModel')} + /> + ))} +
    + )} +
    +
    + + m.name)} + onSubmit={async (item) => { + await handleAddCustom(item); + setDialogOpen(false); + }} + submitText={tc('confirm')} + cancelText={tc('cancel')} + /> + + { + if (!open) setEditingModel(null); + }} + title={tSetting('editModel')} + fields={editModelDialogFields} + existingNames={models + .filter((m) => m.name !== editingModel?.name) + .map((m) => m.name)} + defaultValues={editDefaultValues} + loading={editLoading} + onSubmit={async (item) => { + await handleEditSubmit(item); + }} + submitText={tc('confirm')} + cancelText={tc('cancel')} + /> +
    + ); +} + +export default ModelsSection; diff --git a/web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts b/web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts new file mode 100644 index 0000000000..341810e57a --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/models-section/interface.ts @@ -0,0 +1,120 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IProviderInstance } from '@/interfaces/database/llm'; +import { IModelInfo, IProviderModelItem } from '@/interfaces/request/llm'; + +/** State of the per-row "verify model" button. */ +export type VerifyStatus = 'idle' | 'loading' | 'success' | 'error'; + +export interface ModelsSectionProps { + providerName: string; + instanceName: string; + /** Optional — used to populate api_key/base_url for the verify and list calls. */ + instance?: IProviderInstance; + /** + * If true, hides the List Models / + buttons (used in the "new instance" + * draft state where there is no backend instance to query yet). + */ + hideActions?: boolean; + /** + * If true, the section renders nothing once the first catalog fetch + * completes and no models are available. Used by draft instances to + * avoid showing an empty list. + */ + hideIfEmpty?: boolean; + /** + * Optional getter returning the host card's current form values + * (`api_key`, `base_url` / `api_base`, region-specific fields, ...). + * When provided, ModelsSection prefers these over the persisted + * `instance` props when calling listProviderModels / verifyProviderConnection, + * so the user can verify with values they are still editing (before + * blur-save persists them to the backend). + */ + getFormValues?: () => Record; + /** + * Notifies the host that ModelsSection has opened (or closed) a modal + * dialog whose contents live in a React Portal outside the host's + * `onBlurCapture` container. The host should temporarily disable its + * blur-driven auto-save while suppressed === true; otherwise the + * focus shift into the dialog body fires a spurious "save". Restored + * to false when the dialog closes. + */ + onBlurSuppressChange?: (suppressed: boolean) => void; + /** + * Notifies the host whenever the per-instance model list changes. + * The list is delivered already converted to the `IModelInfo[]` + * shape expected by the update / add-provider-instance endpoints, + * so the host can forward it verbatim in its auto-save payload. + * Fires once on mount with `[]` (initial empty state) and again + * whenever the per-instance fetch resolves or an add/remove mutation + * settles and the cache invalidates. + */ + onInstanceModelsChange?: (modelInfo: IModelInfo[]) => void; + /** + * Optional callback fired when the per-instance model list changes + * in a way that does NOT need the host to re-sync via its own + * auto-save — i.e. an existing model was patched (max_tokens / + * model_type / features changed via the edit dialog) but the model + * set stayed the same. + * + * The PATCH endpoint already persisted the change server-side, so + * the host uses this signal to absorb the resulting model_info diff + * into its last-saved baseline. The next blur-driven auto-save will + * then short-circuit as a no-op (signature matches), avoiding a + * redundant PUT that re-sends the already-saved model_info. + * + * Pair with `onInstanceModelsChange`: that callback fires for every + * change (add/remove/patch) so the host can keep its `modelInfoRef` + * current, while `onInstanceModelsEdited` fires ONLY for patches so + * the host can suppress its next auto-save for an already-persisted + * change. + */ + onInstanceModelsEdited?: () => void; +} + +export interface ModelTypeBadgesProps { + types: string[]; + showEdit?: boolean; + onEdit?: () => void; + editLabel?: string; + editTestSuffix?: string; +} + +export interface ModelVerifyButtonProps { + status: VerifyStatus; + onVerify: () => void; + modelName: string; +} + +export interface ModelRowProps { + model: IProviderModelItem; + isAdded: boolean; + verifyStatus: VerifyStatus; + hideActions: boolean; + onVerify: () => void; + onAdd: () => void; + onRemove: () => void; + onEdit: () => void; + editLabel: string; +} + +export interface TagFilterButtonProps { + label: string; + count: number; + active: boolean; + onClick: () => void; +} diff --git a/web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx new file mode 100644 index 0000000000..9cf3d85e69 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/provider-instance-card.tsx @@ -0,0 +1,237 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DynamicFormRef } from '@/components/dynamic-form'; +import { IModelInfo } from '@/interfaces/request/llm'; +import { useEffect, useRef, useState } from 'react'; +import { useFetchInstanceNameSet, useHideWhenInstanceExists } from '../hooks'; +import { BedrockInstanceCard } from './bedrock-instance-card'; +import { DraftModeCard } from './components/draft-mode-card'; +import { InstanceNameSection } from './components/instance-name-section'; +import { SavedModeCard } from './components/saved-mode-card'; +import { SoMarkInstanceCard } from './somark-instance-card'; +import { + useDeleteInstance, + useDraftAutoSave, + useFormFields, + useFormResetOnDetailsLoad, + useLazyInstanceDetails, + useProviderBaseUrlOptions, + useProviderInitialValues, + useSaveInstanceName, + useSavedAutoSave, + useVerifyProvider, +} from './hooks'; +import { ProviderInstanceCardProps } from './interface'; + +/** + * One inline provider-instance card. The provider name + doc-link arrow + * live in the parent page's sticky `ProviderHeaderBar`; this card only + * shows the **instance**-level details (name, fields, verify, models). + * + * Two visual modes (driven by the `nameSaved` flag, not the `isDraft` + * prop — `isDraft` only controls whether the form is editable): + * 1. **Unsaved name** (`!nameSaved`): the instance name lives in a + * dedicated form-field section at the top of the body, wrapped in + * a red border with a label, input, inline Save button, and + * always-visible helper text. The form fields are always visible + * (no collapsible). The auto-save on blur is *active* but will + * refuse to call `onSaved` until the name is entered and saved. + * 2. **Saved name** (`nameSaved`): the form-field section collapses + * into a single collapsible row showing the name as plain text + * with a hover-only key/lock icon. The form fields live inside + * the collapsible content and can be collapsed/expanded. + */ +export function ProviderInstanceCard(props: ProviderInstanceCardProps) { + // AWS Bedrock has provider-specific fields (auth_mode, region, AK/SK, + // role ARN, model name, max_tokens) that don't fit the generic + // DynamicForm path. Render its own inline card instead. + // + // SoMark is similar: its many provider-specific fields (image / + // formula / table / cs formats + 7 boolean feature toggles) don't + // fit the generic DynamicForm path. Render its own inline card too. + // + // Dispatch BEFORE any hooks so each branch component has a stable + // hook-call order (Rules of Hooks). + if (props.providerName === 'Bedrock') { + return ; + } + if (props.providerName === 'SoMark') { + return ; + } + return ; +} + +function GenericProviderInstanceCard({ + providerName, + instance, + isDraft = false, + onSaved, + onNameSaved, + onDelete, + defaultOpen = false, +}: ProviderInstanceCardProps) { + // Drafts always start open (the user just added them and needs to + // fill the fields); saved cards default to collapsed unless the + // parent flagged this card as the one to expand initially (typically + // the first instance in the list). + const [open, setOpen] = useState(isDraft || defaultOpen); + // Drafts start with an empty name — the user types it themselves. + const [draftName, setDraftName] = useState(''); + // Tracks whether the instance name has been saved for the current + // draft/saved state. Saved instances start with `true` (the name is + // persisted in the backend); draft instances start with `false` and + // flip to `true` after the dedicated "Save" button on the name + // section is pressed. + const [nameSaved, setNameSaved] = useState(!isDraft); + const formRef = useRef(null); + // Mirror of the per-instance model list — written by ModelsSection + // via `setModelInfo`, read by the auto-save payload assembler. + const modelInfoRef = useRef([]); + + useEffect(() => { + if (isDraft) { + setDraftName(''); + setNameSaved(false); + } else { + setNameSaved(true); + } + }, [providerName, isDraft]); + + // ── Data fetching ──────────────────────────────────────────────── + const { instanceNameSet } = useFetchInstanceNameSet( + isDraft ? providerName : '', + ); + const hideWhenInstanceExists = useHideWhenInstanceExists(instanceNameSet); + const { baseUrlOptions } = useProviderBaseUrlOptions(providerName); + const { instanceDetails } = useLazyInstanceDetails( + providerName, + instance.instance_name, + isDraft, + open, + ); + + // ── Form initial values + fields ──────────────────────────────── + const initialValues = useProviderInitialValues( + instance, + instanceDetails, + isDraft, + baseUrlOptions, + ); + const { formFields, formDefaultValues } = useFormFields( + providerName, + isDraft, + initialValues, + baseUrlOptions, + hideWhenInstanceExists, + ); + useFormResetOnDetailsLoad( + formRef, + formDefaultValues, + instanceDetails, + isDraft, + ); + + // ── Action handlers ───────────────────────────────────────────── + const handleVerify = useVerifyProvider(providerName, formRef); + const handleSaveName = useSaveInstanceName( + providerName, + draftName, + onNameSaved, + ); + const handleDelete = useDeleteInstance( + providerName, + instance.instance_name, + isDraft, + onDelete, + ); + + // ── Auto-save wiring ───────────────────────────────────────────── + // Draft: 200ms-debounced watch effect, gated on the instance name + // being entered and saved. + useDraftAutoSave( + formRef, + isDraft, + nameSaved, + draftName, + isDraft ? onSaved : undefined, + modelInfoRef, + ); + + // Saved: blur-driven + dropdown value-change auto-save via PUT. + const { handleFieldsBlur, blurSuppressRef, markModelsEdited } = + useSavedAutoSave({ + formRef, + formFields, + providerName, + instanceName: instance.instance_name, + instanceId: instance.id, + isDraft, + instanceDetails, + initialValues, + modelInfoRef, + }); + + return ( +
    + {nameSaved ? ( + + ) : ( + + )} +
    + ); +} + +// Re-export the name section for callers that need to embed it +// (e.g. parent pages with custom layouts). +export { InstanceNameSection }; + +export default ProviderInstanceCard; diff --git a/web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx b/web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx new file mode 100644 index 0000000000..84f56c59f6 --- /dev/null +++ b/web/src/pages/user-setting/setting-model/instance-card/somark-instance-card.tsx @@ -0,0 +1,845 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog'; +import { RAGFlowFormItem } from '@/components/ragflow-form'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Form } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { RAGFlowSelect } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { useTranslate } from '@/hooks/common-hooks'; +import { + useAddProviderInstance, + useDeleteProviderInstance, + useFetchProviderInstance, + useVerifyProviderConnection, +} from '@/hooks/use-llm-request'; +import { IProviderInstance } from '@/interfaces/database/llm'; +import { IAddProviderInstanceRequestBody } from '@/interfaces/request/llm'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { ListChevronsDownUp, ListChevronsUpDown, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; +import { VerifyResult } from '../hooks'; +import VerifyButton from './verify-button'; + +const IMAGE_FORMATS = ['url', 'base64', 'none'] as const; +const FORMULA_FORMATS = ['latex', 'mathml', 'ascii'] as const; +const TABLE_FORMATS = ['html', 'markdown', 'image'] as const; +const CS_FORMATS = ['image'] as const; +const FORMAT_LABELS = { + url: 'URL', + base64: 'Base64', + none: 'None', + latex: 'LaTeX', + mathml: 'MathML', + ascii: 'ASCII', + html: 'HTML', + markdown: 'Markdown', + image: 'Image', +} as const; + +const buildFormatOptions = ( + formats: readonly T[], +) => formats.map((value) => ({ label: FORMAT_LABELS[value], value })); + +// Field names whose value commits via click (Selects, Switches) rather +// than blur. Their popovers render in Radix portals outside the card's +// blur container, so blur-driven saves don't catch them — a form.watch +// watcher is used instead to schedule a save when they change. +const SOMARK_WATCHED_FIELDS = new Set([ + 'somark_image_format', + 'somark_formula_format', + 'somark_table_format', + 'somark_cs_format', + 'somark_enable_text_cross_page', + 'somark_enable_table_cross_page', + 'somark_enable_title_level_recognition', + 'somark_enable_inline_image', + 'somark_enable_table_image', + 'somark_enable_image_understanding', + 'somark_keep_header_footer', +]); + +type SoMarkFormValues = { + llm_name: string; + somark_base_url: string; + somark_api_key?: string; + somark_image_format: (typeof IMAGE_FORMATS)[number]; + somark_formula_format: (typeof FORMULA_FORMATS)[number]; + somark_table_format: (typeof TABLE_FORMATS)[number]; + somark_cs_format: (typeof CS_FORMATS)[number]; + somark_enable_text_cross_page: boolean; + somark_enable_table_cross_page: boolean; + somark_enable_title_level_recognition: boolean; + somark_enable_inline_image: boolean; + somark_enable_table_image: boolean; + somark_enable_image_understanding: boolean; + somark_keep_header_footer: boolean; +}; + +interface SoMarkInstanceCardProps { + providerName: string; + instance: IProviderInstance; + isDraft?: boolean; + onSaved?: (values: Record) => void | Promise; + onNameSaved?: () => void; + onDelete?: () => void; + /** + * When true, this card starts expanded and fetches its instance + * details on mount. Default `false` so non-first cards stay + * collapsed until the user opens them. + */ + defaultOpen?: boolean; +} + +/** + * Inline instance card for SoMark. Mirrors the two-stage UX of + * `BedrockInstanceCard` (save name first, then edit fields) but renders + * SoMark-specific fields (model name, base URL, API key, 4 element-format + * selects, 7 feature toggles) directly. The model type is fixed to + * `['ocr']` (SoMark is an OCR provider) and not exposed in the form. + * + * Payload shape (matches the legacy `useSubmitSoMark` hook so the + * backend contract is unchanged): + * { + * instance_name, llm_factory: 'SoMark', + * api_key: somark_api_key || '', + * base_url: somark_base_url, + * max_tokens: 0, + * model_info: [{ + * llm_name, model_type: ['ocr'], max_tokens: 0, + * extra: { somark_image_format, somark_formula_format, ... } + * }] + * } + */ +export function SoMarkInstanceCard({ + providerName, + instance, + isDraft = false, + onSaved, + onNameSaved, + onDelete, + defaultOpen = false, +}: SoMarkInstanceCardProps) { + const { t } = useTranslation(); + const { t: tSetting } = useTranslate('setting'); + const [open, setOpen] = useState(isDraft || defaultOpen); + const [draftName, setDraftName] = useState(''); + const [nameSaved, setNameSaved] = useState(!isDraft); + const savingRef = useRef(false); + + useEffect(() => { + if (isDraft) { + setDraftName(''); + setNameSaved(false); + } else { + setNameSaved(true); + } + }, [providerName, isDraft]); + + const FormSchema = useMemo( + () => + z.object({ + llm_name: z.string().min(1, { + message: tSetting('somark.modelNameMessage'), + }), + somark_base_url: z.string().min(1, { + message: tSetting('somark.baseUrlMessage'), + }), + somark_api_key: z.string().optional(), + somark_image_format: z.enum(IMAGE_FORMATS), + somark_formula_format: z.enum(FORMULA_FORMATS), + somark_table_format: z.enum(TABLE_FORMATS), + somark_cs_format: z.enum(CS_FORMATS), + somark_enable_text_cross_page: z.boolean(), + somark_enable_table_cross_page: z.boolean(), + somark_enable_title_level_recognition: z.boolean(), + somark_enable_inline_image: z.boolean(), + somark_enable_table_image: z.boolean(), + somark_enable_image_understanding: z.boolean(), + somark_keep_header_footer: z.boolean(), + }), + [tSetting], + ); + + const { data: instanceDetails, refetch: refetchInstanceDetails } = + useFetchProviderInstance( + isDraft ? '' : providerName, + isDraft ? '' : instance.instance_name, + ); + + // Lazily fetch full instance details only when the card is open. + // Collapsed cards never hit /providers//instances/; + // expanding one triggers a fresh refetch. + useEffect(() => { + if (!isDraft && open && providerName && instance.instance_name) { + refetchInstanceDetails(); + } + }, [ + isDraft, + open, + providerName, + instance.instance_name, + refetchInstanceDetails, + ]); + + // Build initial values from the persisted instance + lazy-loaded details. + // SoMark stores its provider-specific fields inside + // `model_info[0].extra`; `api_key` and `base_url` live at the + // instance top level. Map them back to the form's flat shape. + const initialValues = useMemo(() => { + const merged: any = { ...instance, ...(instanceDetails ?? {}) }; + const rawApiKey = merged.api_key; + const apiKey = + typeof rawApiKey === 'string' + ? rawApiKey + : rawApiKey && typeof rawApiKey === 'object' + ? (rawApiKey.api_key ?? '') + : ''; + const modelInfo = Array.isArray(merged.model_info) + ? merged.model_info[0] + : null; + const extra = (modelInfo?.extra ?? {}) as Record; + return { + llm_name: modelInfo?.llm_name ?? modelInfo?.model_name ?? '', + somark_base_url: (merged.base_url as string) ?? '', + somark_api_key: apiKey, + somark_image_format: (extra.somark_image_format as (typeof IMAGE_FORMATS)[number]) ?? 'url', + somark_formula_format: (extra.somark_formula_format as (typeof FORMULA_FORMATS)[number]) ?? 'latex', + somark_table_format: (extra.somark_table_format as (typeof TABLE_FORMATS)[number]) ?? 'html', + somark_cs_format: (extra.somark_cs_format as (typeof CS_FORMATS)[number]) ?? 'image', + somark_enable_text_cross_page: extra.somark_enable_text_cross_page ?? false, + somark_enable_table_cross_page: extra.somark_enable_table_cross_page ?? false, + somark_enable_title_level_recognition: + extra.somark_enable_title_level_recognition ?? false, + somark_enable_inline_image: extra.somark_enable_inline_image ?? false, + somark_enable_table_image: extra.somark_enable_table_image ?? true, + somark_enable_image_understanding: + extra.somark_enable_image_understanding ?? true, + somark_keep_header_footer: extra.somark_keep_header_footer ?? false, + }; + }, [instance, instanceDetails]); + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: initialValues, + }); + + useEffect(() => { + // Reset form when initial values change (e.g. instance details load). + form.reset(initialValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialValues]); + + const imageFormatOptions = useMemo( + () => buildFormatOptions(IMAGE_FORMATS), + [], + ); + const formulaFormatOptions = useMemo( + () => buildFormatOptions(FORMULA_FORMATS), + [], + ); + const tableFormatOptions = useMemo( + () => buildFormatOptions(TABLE_FORMATS), + [], + ); + const csFormatOptions = useMemo(() => buildFormatOptions(CS_FORMATS), []); + + // Build a SoMark-shaped payload for both submit and verify flows. + // Mirrors the legacy `useSubmitSoMark` hook so the backend contract + // is unchanged: api_key/base_url at the instance level, all somark_* + // feature/format fields inside model_info[0].extra. + const buildPayload = useCallback( + (values: SoMarkFormValues, instanceName: string) => { + const extra = { + somark_image_format: values.somark_image_format, + somark_formula_format: values.somark_formula_format, + somark_table_format: values.somark_table_format, + somark_cs_format: values.somark_cs_format, + somark_enable_text_cross_page: values.somark_enable_text_cross_page, + somark_enable_table_cross_page: values.somark_enable_table_cross_page, + somark_enable_title_level_recognition: + values.somark_enable_title_level_recognition, + somark_enable_inline_image: values.somark_enable_inline_image, + somark_enable_table_image: values.somark_enable_table_image, + somark_enable_image_understanding: + values.somark_enable_image_understanding, + somark_keep_header_footer: values.somark_keep_header_footer, + }; + return { + instance_name: instanceName, + llm_factory: providerName, + api_key: values.somark_api_key ?? '', + base_url: values.somark_base_url, + max_tokens: 0, + model_info: [ + { + model_name: values.llm_name, + model_type: ['ocr'], + max_tokens: 0, + extra, + }, + ], + } as unknown as IAddProviderInstanceRequestBody; + }, + [providerName], + ); + + const { verifyProviderConnection } = useVerifyProviderConnection(); + const handleVerify = useCallback( + async (params: any) => { + const isValid = await form.trigger(); + if (!isValid) { + return { + isValid: false, + logs: tSetting('somark.baseUrlMessage'), + } as VerifyResult; + } + const values = form.getValues(); + const payload = buildPayload( + values, + draftName.trim() || instance.instance_name, + ); + const ret = await verifyProviderConnection({ + provider_name: providerName, + api_key: (payload as any).api_key, + base_url: (payload as any).base_url, + model_info: (payload as any).model_info, + ...params, + }); + return { + isValid: ret.code === 0, + logs: ret.message, + } as VerifyResult; + }, + [ + form, + providerName, + buildPayload, + draftName, + instance.instance_name, + verifyProviderConnection, + tSetting, + ], + ); + + const { addProviderInstance } = useAddProviderInstance(); + + const handleSaveName = useCallback(async () => { + const trimmed = draftName.trim(); + if (!trimmed) return; + const ret = await addProviderInstance({ + llm_factory: providerName, + instance_name: trimmed, + } as any); + if (ret?.code === 0) { + onNameSaved?.(); + } + }, [draftName, addProviderInstance, providerName, onNameSaved]); + + // Auto-save in draft mode after the name is locked. Debounced on form + // value changes; refuses to fire until validation passes. + useEffect(() => { + if (!isDraft) return; + if (!nameSaved) return; + let saveTimeout: ReturnType | null = null; + let cancelled = false; + const sub = form.watch(() => { + if (saveTimeout) clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + if (cancelled || savingRef.current) return; + const isValid = await form.trigger(); + if (cancelled || savingRef.current) return; + if (!isValid) return; + const trimmed = draftName.trim(); + if (!trimmed) return; + savingRef.current = true; + try { + const values = form.getValues(); + const payload = buildPayload(values, trimmed); + await onSaved?.(payload as unknown as Record); + } finally { + savingRef.current = false; + } + }, 200); + }); + return () => { + cancelled = true; + if (saveTimeout) clearTimeout(saveTimeout); + try { + sub?.unsubscribe?.(); + } catch { + // ignore + } + }; + }, [isDraft, nameSaved, form, draftName, buildPayload, onSaved]); + + // Saved-mode auto-save. Both blur-driven (text inputs) and + // change-driven (Selects / Switches) edits are coalesced through + // a shared debounced `scheduleSave`. Selects render in Radix portals + // outside the card's blur container, and Switches are click-based + // (no blur), so a `form.watch` watcher is needed to catch them. + const blurSavingRef = useRef(false); + const lastSavedSigRef = useRef(''); + const autoSaveTimeoutRef = useRef | null>( + null, + ); + const AUTO_SAVE_DEBOUNCE_MS = 500; + + const performSave = useCallback(async () => { + if (isDraft) return; + if (blurSavingRef.current) return; + const isValid = await form.trigger(); + if (!isValid) return; + const values = form.getValues(); + const payload = buildPayload(values, instance.instance_name); + const finalPayload = { + ...payload, + id: instanceDetails?.id || instance.id, + }; + const sig = JSON.stringify(finalPayload); + if (sig === lastSavedSigRef.current) return; + blurSavingRef.current = true; + try { + const ret = await addProviderInstance(finalPayload as any); + if (ret?.code === 0) { + lastSavedSigRef.current = sig; + } + } finally { + blurSavingRef.current = false; + } + }, [ + isDraft, + form, + buildPayload, + instance.instance_name, + instance.id, + instanceDetails?.id, + addProviderInstance, + ]); + + const scheduleSave = useCallback(() => { + if (isDraft) return; + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + } + autoSaveTimeoutRef.current = setTimeout(() => { + autoSaveTimeoutRef.current = null; + void performSave(); + }, AUTO_SAVE_DEBOUNCE_MS); + }, [isDraft, performSave]); + + const handleFieldsBlur = useCallback( + (e: React.FocusEvent) => { + if (isDraft) return; + if ( + e.currentTarget.contains(e.relatedTarget as Node | null) && + e.relatedTarget !== null + ) { + return; + } + scheduleSave(); + }, + [isDraft, scheduleSave], + ); + + // Dropdown / Switch change-driven save (saved mode only). Text + // inputs are handled by blur; Selects and Switches commit via click + // and their popovers live in portals, so we watch the form directly. + // Only react to user-driven changes (type === 'change'); ignore + // programmatic resets (form.reset when instanceDetails loads). + useEffect(() => { + if (isDraft) return; + if (!instanceDetails) return; + let cancelled = false; + const subscription = form.watch( + (_values: any, meta: { name?: string; type?: string }) => { + if (cancelled) return; + if (meta?.type !== 'change') return; + if (!meta?.name || !SOMARK_WATCHED_FIELDS.has(meta.name)) return; + scheduleSave(); + }, + ); + return () => { + cancelled = true; + try { + subscription?.unsubscribe?.(); + } catch { + // ignore + } + }; + }, [isDraft, instanceDetails, form, scheduleSave]); + + // Clear pending save on unmount. + useEffect(() => { + return () => { + if (autoSaveTimeoutRef.current) { + clearTimeout(autoSaveTimeoutRef.current); + autoSaveTimeoutRef.current = null; + } + }; + }, []); + + const { deleteProviderInstance } = useDeleteProviderInstance(); + const handleDelete = useCallback(async () => { + if (isDraft) { + onDelete?.(); + } else { + await deleteProviderInstance({ + provider_name: providerName, + instances: [instance.instance_name], + }); + } + }, [ + isDraft, + providerName, + instance.instance_name, + deleteProviderInstance, + onDelete, + ]); + + // ──────────────── Field group rendered in both modes ──────────────── + const renderFields = () => ( +
    + e.preventDefault()}> + + + + + + + + + + + + +
    + {tSetting('somark.sectionElementFormats')} +
    + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + +
    + {tSetting('somark.sectionFeatureConfig')} +
    + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + + + + {(field) => ( + + )} + +
    + + {/* VerifyButton lives inside
    (FormProvider) so its + internal useFormContext() resolves the form instance. + Rendered outside so it never triggers submission. */} +
    + +
    +
    + ); + + return ( +
    + {nameSaved ? ( + + +
    +
    + + + {draftName || instance.instance_name} + +
    + + + +
    +
    + +
    + {renderFields()} +
    +
    +
    + ) : ( +
    +
    + +
    + setDraftName(e.target.value)} + placeholder={tSetting('instanceNamePlaceholder')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSaveName(); + } + }} + className="flex-1 rounded-r-none" + data-testid="instance-name-input" + /> + + + + +
    +

    + {tSetting('instanceNameSaveTip')} +

    +
    + +
    + {renderFields()} +
    +
    + )} +
    + ); +} + +export default SoMarkInstanceCard; diff --git a/web/src/pages/user-setting/setting-model/modal/provider-modal/components/use-custom-model-fields.tsx b/web/src/pages/user-setting/setting-model/instance-card/use-custom-model-fields.tsx similarity index 76% rename from web/src/pages/user-setting/setting-model/modal/provider-modal/components/use-custom-model-fields.tsx rename to web/src/pages/user-setting/setting-model/instance-card/use-custom-model-fields.tsx index a763539407..f630a61787 100644 --- a/web/src/pages/user-setting/setting-model/modal/provider-modal/components/use-custom-model-fields.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/use-custom-model-fields.tsx @@ -1,3 +1,19 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { useTranslate } from '@/hooks/common-hooks'; import { useMemo } from 'react'; import type { AddCustomModelDialogFields } from './add-custom-model-dialog'; diff --git a/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx b/web/src/pages/user-setting/setting-model/instance-card/verify-button.tsx similarity index 78% rename from web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx rename to web/src/pages/user-setting/setting-model/instance-card/verify-button.tsx index 039bfba059..2beba3f36f 100644 --- a/web/src/pages/user-setting/setting-model/modal/verify-button/index.tsx +++ b/web/src/pages/user-setting/setting-model/instance-card/verify-button.tsx @@ -1,3 +1,19 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { Button } from '@/components/ui/button'; import { useTranslate } from '@/hooks/common-hooks'; import { cn } from '@/lib/utils'; @@ -6,7 +22,7 @@ import { ApiKeyPostBody } from '@/pages/user-setting/interface'; import { RefreshCcw } from 'lucide-react'; import { memo, useCallback, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { VerifyResult } from '../../hooks'; +import { VerifyResult } from '../hooks'; interface IVerifyButton { onVerify: (params: any) => Promise; @@ -18,6 +34,15 @@ interface IVerifyButton { /** Override the failure label shown next to the button. Defaults to t('keyInvalid'). */ invalidLabel?: string; verifyCallback?: (result: VerifyResult | null) => void; + /** + * Optional ref to a form-like object exposing `trigger()` and + * `getValues()`. Use this when the button is rendered as a *sibling* + * of the form (i.e. outside any FormProvider). When omitted, falls + * back to react-hook-form's `useFormContext()`. + */ + formRef?: { + current: { trigger: () => Promise; getValues: () => any } | null; + }; } const VerifyButton: React.FC = ({ @@ -28,6 +53,7 @@ const VerifyButton: React.FC = ({ validLabel, invalidLabel, verifyCallback, + formRef, }) => { const { t, i18n } = useTranslate('setting'); const isArabic = (i18n.resolvedLanguage || i18n.language || '') @@ -35,7 +61,8 @@ const VerifyButton: React.FC = ({ .startsWith('ar'); const [isVerifying, setIsVerifying] = useState(false); const [verifyResult, setVerifyResult] = useState(null); - const form = useFormContext(); + const contextForm = useFormContext(); + const form = formRef?.current ?? contextForm; const onHandleVerify = useCallback(async () => { const formValid = await form?.trigger(); diff --git a/web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx b/web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx new file mode 100644 index 0000000000..a4d7f2448a --- /dev/null +++ b/web/src/pages/user-setting/setting-model/layout/provider-header-bar.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LlmIcon } from '@/components/svg-icon'; +import { APIMapUrl } from '@/constants/llm'; +import { useTranslate } from '@/hooks/common-hooks'; +import { ArrowUpRight } from 'lucide-react'; + +interface ProviderHeaderBarProps { + providerName: string; +} + +/** + * Sticky top bar for the right pane that displays the selected provider's + * icon, name and a doc-link arrow. Stays visible while the user scrolls + * the instance list below. + */ +export function ProviderHeaderBar({ providerName }: ProviderHeaderBarProps) { + const { t: tSetting } = useTranslate('setting'); + const docLink = APIMapUrl[providerName as keyof typeof APIMapUrl]; + + return ( +
    + + {providerName} + {docLink && ( + + + + )} +
    + ); +} + +export default ProviderHeaderBar; diff --git a/web/src/pages/user-setting/setting-model/layout/sidebar.tsx b/web/src/pages/user-setting/setting-model/layout/sidebar.tsx new file mode 100644 index 0000000000..0a911fdf2d --- /dev/null +++ b/web/src/pages/user-setting/setting-model/layout/sidebar.tsx @@ -0,0 +1,153 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LlmIcon } from '@/components/svg-icon'; +import { SearchInput } from '@/components/ui/input'; +import { + useFetchAddedProviders, + useFetchAvailableProviders, +} from '@/hooks/use-llm-request'; +import { cn } from '@/lib/utils'; +import { ChevronRight } from 'lucide-react'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * Sidebar selection type for the right pane. + * - 'default': show the system default models view. + * - any string: a provider (LLM factory) name; show its instance list. + */ +export type SidebarSelection = 'default' | string; + +interface SidebarProps { + selection: SidebarSelection; + onSelect: (v: SidebarSelection) => void; +} + +/** + * Left column of the v2 settings page. + * + * Layout (top -> bottom): + * 1. "Default models" entry — highlighted when `selection === 'default'`, + * with a chevron on the right. + * 2. "Available models" section header. + * 3. Search input — case-insensitive filter over provider names. + * 4. Scrollable list of available providers — clicking a row highlights + * it and triggers `onSelect(providerName)`. Providers that already + * have at least one configured instance show a green dot on the + * right. + */ +export function Sidebar({ selection, onSelect }: SidebarProps) { + const { t } = useTranslation(); + const { data: providers } = useFetchAvailableProviders(); + const { data: addedProviders } = useFetchAddedProviders(); + const [search, setSearch] = useState(''); + + // Any provider present in `addedProviders` is treated as "added" and + // sorted first in the list. We deliberately do NOT fetch each added + // provider's instance list on mount — instance details are fetched + // lazily by the right pane only when the user clicks a provider. + const addedSet = useMemo(() => { + return new Set( + addedProviders.filter((p) => p.has_instance).map((p) => p.name), + ); + }, [addedProviders]); + + const filteredProviders = useMemo(() => { + const q = search.trim().toLowerCase(); + const list = q + ? providers.filter((p) => p.name.toLowerCase().includes(q)) + : providers; + // Stable partition: added providers first, then unadded. + const added = list.filter((p) => addedSet.has(p.name)); + const others = list.filter((p) => !addedSet.has(p.name)); + return [...added, ...others]; + }, [providers, search, addedSet]); + + return ( +
    + + +
    + {t('setting.availableModels')} +
    + + setSearch(e.target.value)} + placeholder={t('setting.search')} + data-testid="sidebar-provider-search" + className="w-full border-none" + /> + +
    + {filteredProviders.map((provider) => { + const isActive = selection === provider.name; + const isAdded = addedSet.has(provider.name); + return ( + + ); + })} + {filteredProviders.length === 0 && ( +
    + {t('setting.empty')} +
    + )} +
    +
    + ); +} + +export default Sidebar; diff --git a/web/src/pages/user-setting/setting-model/components/system-setting.tsx b/web/src/pages/user-setting/setting-model/layout/system-setting.tsx similarity index 82% rename from web/src/pages/user-setting/setting-model/components/system-setting.tsx rename to web/src/pages/user-setting/setting-model/layout/system-setting.tsx index 99f130ef3e..0e97dda580 100644 --- a/web/src/pages/user-setting/setting-model/components/system-setting.tsx +++ b/web/src/pages/user-setting/setting-model/layout/system-setting.tsx @@ -1,3 +1,19 @@ +/* + * Copyright 2026 The InfiniFlow Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + import { ModelTreeSelect, ModelTypeMap } from '@/components/model-tree-select'; import { Tooltip, @@ -34,8 +50,8 @@ function ModelFieldItem({ const { t } = useTranslate('setting'); return ( -
    -