feat(server): 设置持久化新增 LLM 模型表与主题字段
- SettingsLlmForm 新增 models 列表(SettingsModelRow:slot/provider/url/apiKey/modelId/type),支持多模型行持久化 - settings 服务读写模型表与主题相关字段,更新 test_settings_persistence 测试
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ MODEL_SLOT_CONFIGS = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
class AdminCredentialRecord:
|
class AdminCredentialRecord:
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -148,6 +148,46 @@ def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
|
|||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user