From 9c3fa80d22d64d06e29f365026c68db913591c71 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Fri, 26 Jun 2026 22:41:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E8=AE=BE=E7=BD=AE=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E6=96=B0=E5=A2=9E=20LLM=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E4=B8=BB=E9=A2=98=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsLlmForm 新增 models 列表(SettingsModelRow:slot/provider/url/apiKey/modelId/type),支持多模型行持久化 - settings 服务读写模型表与主题相关字段,更新 test_settings_persistence 测试 --- server/src/app/schemas/settings.py | 22 ++++- server/src/app/services/settings.py | 109 ++++++++++++++++++++-- server/tests/test_settings_persistence.py | 44 ++++++++- 3 files changed, 164 insertions(+), 11 deletions(-) diff --git a/server/src/app/schemas/settings.py b/server/src/app/schemas/settings.py index ebfd375..2e8b315 100644 --- a/server/src/app/schemas/settings.py +++ b/server/src/app/schemas/settings.py @@ -56,6 +56,23 @@ class SettingsSessionForm(BaseModel): conversationRetentionDays: int = Field(default=3, ge=1, le=10) +class SettingsModelRow(BaseModel): + slot: str = Field(min_length=1, max_length=64) + provider: str = Field(min_length=1, max_length=64) + url: str = Field(min_length=1, max_length=512) + apiKey: str = Field(default="", max_length=1024) + apiKeyConfigured: bool = False + modelId: str = Field(min_length=1, max_length=255) + type: Literal["llm", "embedding", "rerank"] = "llm" + + @field_validator("slot", "provider", "url", "apiKey", "modelId", mode="before") + @classmethod + def strip_model_row_string(cls, value: str | None) -> str | None: + if value is None: + return None + return value.strip() + + class SettingsLlmForm(BaseModel): mainProvider: str = Field(min_length=1, max_length=64) mainModel: str = Field(min_length=1, max_length=255) @@ -80,6 +97,7 @@ class SettingsLlmForm(BaseModel): rerankerEndpoint: str = Field(min_length=1, max_length=512) rerankerApiKey: str = Field(default="", max_length=1024) rerankerApiKeyConfigured: bool = False + models: list[SettingsModelRow] = Field(default_factory=list) @field_validator( "mainProvider", @@ -201,7 +219,7 @@ class ModelConnectivityTestRequest(BaseModel): model: str = Field(min_length=1, max_length=255) api_key: str | None = Field(default=None, max_length=1024) capability: Literal["chat", "embedding", "reranker"] = "chat" - slot: Literal["main", "backup", "embedding", "reranker"] | None = None + slot: str | None = Field(default=None, max_length=64) @field_validator("provider", "endpoint", "model", "api_key", mode="before") @classmethod @@ -234,7 +252,7 @@ class SettingsCacheClearRead(BaseModel): class RuntimeModelConfigRead(BaseModel): - slot: Literal["main", "backup", "embedding", "reranker"] + slot: str provider: str model: str endpoint: str diff --git a/server/src/app/services/settings.py b/server/src/app/services/settings.py index c7070d0..a4e3c05 100644 --- a/server/src/app/services/settings.py +++ b/server/src/app/services/settings.py @@ -93,6 +93,18 @@ MODEL_SLOT_CONFIGS = { priority=40, ), } + +MODEL_TYPE_TO_CAPABILITY = { + "llm": "chat", + "embedding": "embedding", + "rerank": "reranker", +} + +MODEL_CAPABILITY_TO_TYPE = { + "chat": "llm", + "embedding": "embedding", + "reranker": "rerank", +} @dataclass(slots=True) @@ -110,6 +122,26 @@ class OnlyOfficeRuntimeConfig: jwt_secret: str +def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict[str, object]]: + ordered_rows = sorted( + model_rows.values(), + key=lambda row: (int(row.priority or 0), str(row.slot or "")), + ) + + return [ + { + "slot": row.slot, + "provider": row.provider, + "url": row.endpoint, + "apiKey": "", + "apiKeyConfigured": bool(row.api_key_encrypted), + "modelId": row.model_name, + "type": MODEL_CAPABILITY_TO_TYPE.get(str(row.capability or "chat"), "llm"), + } + for row in ordered_rows + ] + + class SettingsService: _schema_ready_lock = threading.Lock() _schema_ready_keys: set[tuple[str, int]] = set() @@ -282,6 +314,8 @@ class SettingsService: payload.llmForm.rerankerEndpoint, payload.llmForm.rerankerApiKey, ) + if payload.llmForm.models: + self._apply_model_rows(model_rows, payload.llmForm.models) if payload.renderForm.enabled and not payload.renderForm.publicUrl: raise ValueError("启用 ONLYOFFICE 时必须配置服务地址。") @@ -367,31 +401,39 @@ class SettingsService: ) def load_saved_model_api_key(self, slot: str | None) -> str: - if not slot or slot not in MODEL_SLOT_CONFIGS: + normalized_slot = str(slot or "").strip() + if not normalized_slot: return "" settings_row, secrets_row = self.ensure_settings_ready() model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) - encrypted_value = model_rows[slot].api_key_encrypted + model_row = model_rows.get(normalized_slot) + if model_row is None: + return "" + + encrypted_value = model_row.api_key_encrypted if not encrypted_value: return "" - return self._decrypt_model_api_key(encrypted_value, slot=slot) + return self._decrypt_model_api_key(encrypted_value, slot=normalized_slot) def get_runtime_model_config(self, slot: str) -> dict[str, str]: - if slot not in MODEL_SLOT_CONFIGS: + normalized_slot = str(slot or "").strip() + if not normalized_slot: raise ValueError("未知模型槽位。") settings_row, secrets_row = self.ensure_settings_ready() model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) - model_row = model_rows[slot] + model_row = model_rows.get(normalized_slot) + if model_row is None: + raise ValueError("未知模型槽位。") return { - "slot": slot, + "slot": normalized_slot, "provider": model_row.provider, "model": model_row.model_name, "endpoint": model_row.endpoint, - "apiKey": self.load_saved_model_api_key(slot), + "apiKey": self.load_saved_model_api_key(normalized_slot), "capability": model_row.capability, } @@ -550,9 +592,61 @@ class SettingsService: model_row.endpoint = endpoint normalized_api_key = api_key.strip() + if normalized_api_key == "********": + return if normalized_api_key: model_row.api_key_encrypted = encrypt_secret(normalized_api_key) + def _apply_model_rows(self, model_rows: dict[str, SystemModelSetting], rows: list[object]) -> None: + seen_slots: set[str] = set() + active_custom_slots: set[str] = set() + + for index, row in enumerate(rows, start=1): + slot = str(getattr(row, "slot", "") or "").strip() + if not slot: + raise ValueError("模型配置缺少槽位标识。") + if slot in seen_slots: + raise ValueError(f"模型槽位重复:{slot}") + seen_slots.add(slot) + if slot in MODEL_SLOT_CONFIGS: + continue + + provider = str(getattr(row, "provider", "") or "").strip() + model_id = str(getattr(row, "modelId", "") or "").strip() + url = str(getattr(row, "url", "") or "").strip() + model_type = str(getattr(row, "type", "") or "llm").strip() + capability = MODEL_TYPE_TO_CAPABILITY.get(model_type) + if capability is None: + raise ValueError("模型类型必须是大语言模型、Embedding 或 Rerank。") + if not provider or not model_id or not url: + raise ValueError("模型配置必须填写供应商、model_id 和接口地址。") + + model_row = model_rows.get(slot) + if model_row is None: + model_row = SystemModelSetting(slot=slot) + model_rows[slot] = model_row + + self._apply_model_setting( + model_row, + provider, + model_id, + url, + str(getattr(row, "apiKey", "") or ""), + ) + model_row.capability = capability + model_row.priority = index * 10 + model_row.enabled = True + self.db.add(model_row) + + if slot not in MODEL_SLOT_CONFIGS: + active_custom_slots.add(slot) + + for slot, model_row in list(model_rows.items()): + if slot in MODEL_SLOT_CONFIGS or slot in active_custom_slots: + continue + self.db.delete(model_row) + model_rows.pop(slot, None) + def _build_hermes_model_route(self, model_row: SystemModelSetting) -> HermesModelRoute: api_key = self._decrypt_model_api_key(model_row.api_key_encrypted, slot=model_row.slot) @@ -804,6 +898,7 @@ class SettingsService: "rerankerEndpoint": reranker_model.endpoint, "rerankerApiKey": "", "rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted), + "models": serialize_model_rows(model_rows), }, renderForm={ "enabled": settings_row.onlyoffice_enabled, diff --git a/server/tests/test_settings_persistence.py b/server/tests/test_settings_persistence.py index 14b6753..2770c80 100644 --- a/server/tests/test_settings_persistence.py +++ b/server/tests/test_settings_persistence.py @@ -146,8 +146,48 @@ def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None: assert runtime_model["endpoint"] == "https://api.minimaxi.com/v1" assert runtime_model["apiKey"] == "shared-main-key" assert runtime_model["capability"] == "chat" - - + + +def test_settings_service_persists_additional_model_rows(monkeypatch) -> None: + temp_dir = build_temp_secret_dir() + monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key") + monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes")) + + with build_session(temp_dir / "settings.db") as db: + service = SettingsService(db) + payload = service.get_settings_snapshot().model_dump() + payload["llmForm"]["models"].append( + { + "slot": "llm_expense_audit", + "provider": "MiniMax", + "url": "https://api.minimaxi.com/v1", + "apiKey": "extra-secret", + "apiKeyConfigured": False, + "modelId": "MiniMax-Text-01", + "type": "llm", + } + ) + + saved_snapshot = service.save_settings_snapshot(SettingsWrite(**payload)) + + saved_model = next( + model for model in saved_snapshot.llmForm.models if model.slot == "llm_expense_audit" + ) + assert saved_model.provider == "MiniMax" + assert saved_model.url == "https://api.minimaxi.com/v1" + assert saved_model.modelId == "MiniMax-Text-01" + assert saved_model.type == "llm" + assert saved_model.apiKey == "" + assert saved_model.apiKeyConfigured is True + + model_row = db.get(SystemModelSetting, "llm_expense_audit") + assert model_row is not None + assert model_row.capability == "chat" + assert model_row.priority == 50 + assert service.load_saved_model_api_key("llm_expense_audit") == "extra-secret" + + def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None: temp_dir = build_temp_secret_dir() admin_file = temp_dir / "admin.json"