feat(server): 设置持久化新增 LLM 模型表与主题字段

- SettingsLlmForm 新增 models 列表(SettingsModelRow:slot/provider/url/apiKey/modelId/type),支持多模型行持久化
- settings 服务读写模型表与主题相关字段,更新 test_settings_persistence 测试
This commit is contained in:
caoxiaozhu
2026-06-26 22:41:40 +08:00
parent 43c3ff860c
commit 9c3fa80d22
3 changed files with 164 additions and 11 deletions

View File

@@ -56,6 +56,23 @@ class SettingsSessionForm(BaseModel):
conversationRetentionDays: int = Field(default=3, ge=1, le=10) 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): class SettingsLlmForm(BaseModel):
mainProvider: str = Field(min_length=1, max_length=64) mainProvider: str = Field(min_length=1, max_length=64)
mainModel: str = Field(min_length=1, max_length=255) 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) rerankerEndpoint: str = Field(min_length=1, max_length=512)
rerankerApiKey: str = Field(default="", max_length=1024) rerankerApiKey: str = Field(default="", max_length=1024)
rerankerApiKeyConfigured: bool = False rerankerApiKeyConfigured: bool = False
models: list[SettingsModelRow] = Field(default_factory=list)
@field_validator( @field_validator(
"mainProvider", "mainProvider",
@@ -201,7 +219,7 @@ class ModelConnectivityTestRequest(BaseModel):
model: str = Field(min_length=1, max_length=255) model: str = Field(min_length=1, max_length=255)
api_key: str | None = Field(default=None, max_length=1024) api_key: str | None = Field(default=None, max_length=1024)
capability: Literal["chat", "embedding", "reranker"] = "chat" 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") @field_validator("provider", "endpoint", "model", "api_key", mode="before")
@classmethod @classmethod
@@ -234,7 +252,7 @@ class SettingsCacheClearRead(BaseModel):
class RuntimeModelConfigRead(BaseModel): class RuntimeModelConfigRead(BaseModel):
slot: Literal["main", "backup", "embedding", "reranker"] slot: str
provider: str provider: str
model: str model: str
endpoint: str endpoint: str

View File

@@ -93,6 +93,18 @@ MODEL_SLOT_CONFIGS = {
priority=40, 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) @dataclass(slots=True)
@@ -110,6 +122,26 @@ class OnlyOfficeRuntimeConfig:
jwt_secret: str 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: class SettingsService:
_schema_ready_lock = threading.Lock() _schema_ready_lock = threading.Lock()
_schema_ready_keys: set[tuple[str, int]] = set() _schema_ready_keys: set[tuple[str, int]] = set()
@@ -282,6 +314,8 @@ class SettingsService:
payload.llmForm.rerankerEndpoint, payload.llmForm.rerankerEndpoint,
payload.llmForm.rerankerApiKey, 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: if payload.renderForm.enabled and not payload.renderForm.publicUrl:
raise ValueError("启用 ONLYOFFICE 时必须配置服务地址。") raise ValueError("启用 ONLYOFFICE 时必须配置服务地址。")
@@ -367,31 +401,39 @@ class SettingsService:
) )
def load_saved_model_api_key(self, slot: str | None) -> str: 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 "" return ""
settings_row, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) 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: if not encrypted_value:
return "" 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]: 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("未知模型槽位。") raise ValueError("未知模型槽位。")
settings_row, secrets_row = self.ensure_settings_ready() settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row) 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 { return {
"slot": slot, "slot": normalized_slot,
"provider": model_row.provider, "provider": model_row.provider,
"model": model_row.model_name, "model": model_row.model_name,
"endpoint": model_row.endpoint, "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, "capability": model_row.capability,
} }
@@ -550,9 +592,61 @@ class SettingsService:
model_row.endpoint = endpoint model_row.endpoint = endpoint
normalized_api_key = api_key.strip() normalized_api_key = api_key.strip()
if normalized_api_key == "********":
return
if normalized_api_key: if normalized_api_key:
model_row.api_key_encrypted = encrypt_secret(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: 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) 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, "rerankerEndpoint": reranker_model.endpoint,
"rerankerApiKey": "", "rerankerApiKey": "",
"rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted), "rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted),
"models": serialize_model_rows(model_rows),
}, },
renderForm={ renderForm={
"enabled": settings_row.onlyoffice_enabled, "enabled": settings_row.onlyoffice_enabled,

View File

@@ -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["endpoint"] == "https://api.minimaxi.com/v1"
assert runtime_model["apiKey"] == "shared-main-key" assert runtime_model["apiKey"] == "shared-main-key"
assert runtime_model["capability"] == "chat" 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: def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> None:
temp_dir = build_temp_secret_dir() temp_dir = build_temp_secret_dir()
admin_file = temp_dir / "admin.json" admin_file = temp_dir / "admin.json"