feat: 添加 Hermite 同步服务与导航优化

This commit is contained in:
caoxiaozhu
2026-05-09 09:14:04 +00:00
parent 6d91528b7c
commit 694ee42781
9 changed files with 455 additions and 95 deletions

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
import os
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
@dataclass(frozen=True, slots=True)
class HermesModelRoute:
model: str
endpoint: str
api_key: str = ""
provider_label: str = ""
@dataclass(frozen=True, slots=True)
class HermesConfigSnapshot:
path: Path
existed: bool
content: bytes
def get_hermes_home() -> Path:
configured_home = str(os.getenv("HERMES_HOME", "")).strip()
if configured_home:
return Path(configured_home).expanduser()
return Path.home() / ".hermes"
def get_hermes_config_path() -> Path:
return get_hermes_home() / "config.yaml"
def capture_hermes_config_snapshot(config_path: Path | None = None) -> HermesConfigSnapshot:
target_path = config_path or get_hermes_config_path()
if not target_path.exists():
return HermesConfigSnapshot(path=target_path, existed=False, content=b"")
return HermesConfigSnapshot(path=target_path, existed=True, content=target_path.read_bytes())
def restore_hermes_config_snapshot(snapshot: HermesConfigSnapshot) -> None:
snapshot.path.parent.mkdir(parents=True, exist_ok=True)
if snapshot.existed:
snapshot.path.write_bytes(snapshot.content)
return
if snapshot.path.exists():
snapshot.path.unlink()
def sync_hermes_model_settings(
primary_route: HermesModelRoute,
fallback_route: HermesModelRoute | None = None,
config_path: Path | None = None,
) -> Path:
target_path = config_path or get_hermes_config_path()
target_path.parent.mkdir(parents=True, exist_ok=True)
config = _load_existing_config(target_path)
config["model"] = _build_primary_model_config(primary_route)
if fallback_route is None:
config.pop("fallback_model", None)
else:
config["fallback_model"] = _build_fallback_model_config(fallback_route)
_atomic_yaml_write(target_path, config)
return target_path
def _load_existing_config(config_path: Path) -> dict[str, Any]:
if not config_path.exists():
return {}
raw_content = config_path.read_text(encoding="utf-8")
if not raw_content.strip():
return {}
loaded = yaml.safe_load(raw_content)
if loaded is None:
return {}
if not isinstance(loaded, dict):
raise ValueError(f"Hermes 配置文件格式无效: {config_path}")
return dict(loaded)
def _build_primary_model_config(route: HermesModelRoute) -> dict[str, Any]:
normalized_model = route.model.strip()
normalized_endpoint = route.endpoint.strip().rstrip("/")
if not normalized_model or not normalized_endpoint:
raise ValueError("Hermes 主模型同步失败:模型名称或接口地址为空。")
api_mode = _infer_api_mode(route)
payload: dict[str, Any] = {
"provider": "custom",
"default": normalized_model,
"base_url": normalized_endpoint,
}
if route.api_key.strip():
payload["api_key"] = route.api_key.strip()
else:
payload.pop("api_key", None)
if api_mode != "chat_completions":
payload["api_mode"] = api_mode
else:
payload.pop("api_mode", None)
return payload
def _build_fallback_model_config(route: HermesModelRoute) -> dict[str, Any]:
normalized_model = route.model.strip()
normalized_endpoint = route.endpoint.strip().rstrip("/")
if not normalized_model or not normalized_endpoint:
raise ValueError("Hermes 备份模型同步失败:模型名称或接口地址为空。")
api_mode = _infer_api_mode(route)
payload: dict[str, Any] = {
"provider": "custom",
"model": normalized_model,
"base_url": normalized_endpoint,
}
if route.api_key.strip():
payload["api_key"] = route.api_key.strip()
else:
payload.pop("api_key", None)
if api_mode != "chat_completions":
payload["api_mode"] = api_mode
else:
payload.pop("api_mode", None)
return payload
def _infer_api_mode(route: HermesModelRoute) -> str:
provider_label = route.provider_label.strip().casefold()
endpoint = route.endpoint.strip().lower().rstrip("/")
if provider_label == "claude" or "anthropic.com" in endpoint or endpoint.endswith("/anthropic"):
return "anthropic_messages"
if "api.openai.com" in endpoint or "api.x.ai" in endpoint:
return "codex_responses"
return "chat_completions"
def _atomic_yaml_write(target_path: Path, payload: dict[str, Any]) -> None:
serialized = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=str(target_path.parent),
prefix=f".{target_path.name}.",
delete=False,
) as temp_file:
temp_file.write(serialized)
temp_path = Path(temp_file.name)
temp_path.replace(target_path)

View File

@@ -17,6 +17,12 @@ from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.repositories.settings import SETTINGS_ROW_ID, SettingsRepository
from app.schemas.settings import SettingsRead, SettingsWrite
from app.services.hermes_sync import (
HermesModelRoute,
capture_hermes_config_snapshot,
restore_hermes_config_snapshot,
sync_hermes_model_settings,
)
@dataclass(frozen=True, slots=True)
@@ -32,23 +38,23 @@ class ModelSlotConfig:
priority: int
MODEL_SLOT_CONFIGS = {
"main": ModelSlotConfig(
provider_attr="main_provider",
model_attr="main_model",
endpoint_attr="main_endpoint",
legacy_secret_attr="main_api_key_encrypted",
default_provider="Codex",
default_model="codex-mini-latest",
default_endpoint="https://api.openai.com/v1",
capability="chat",
priority=10,
),
"backup": ModelSlotConfig(
provider_attr="backup_provider",
model_attr="backup_model",
endpoint_attr="backup_endpoint",
legacy_secret_attr="backup_api_key_encrypted",
MODEL_SLOT_CONFIGS = {
"main": ModelSlotConfig(
provider_attr="main_provider",
model_attr="main_model",
endpoint_attr="main_endpoint",
legacy_secret_attr="main_api_key_encrypted",
default_provider="Codex",
default_model="codex-mini-latest",
default_endpoint="https://api.openai.com/v1",
capability="chat",
priority=10,
),
"backup": ModelSlotConfig(
provider_attr="backup_provider",
model_attr="backup_model",
endpoint_attr="backup_endpoint",
legacy_secret_attr="backup_api_key_encrypted",
default_provider="GLM",
default_model="glm-5.1",
default_endpoint="https://open.bigmodel.cn/api/paas/v4/",
@@ -175,9 +181,9 @@ class SettingsService:
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
return self._serialize(settings_row, secrets_row, model_rows)
def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead:
settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
def save_settings_snapshot(self, payload: SettingsWrite) -> SettingsRead:
settings_row, secrets_row = self.ensure_settings_ready()
model_rows = self.ensure_model_settings_ready(settings_row, secrets_row)
if payload.adminForm.newPassword:
if len(payload.adminForm.newPassword) < 5:
@@ -200,18 +206,18 @@ class SettingsService:
settings_row.strong_password = payload.adminForm.strongPassword
settings_row.login_alert_enabled = payload.adminForm.loginAlertEnabled
self._apply_model_setting(
model_rows["main"],
payload.llmForm.mainProvider,
payload.llmForm.mainModel,
payload.llmForm.mainEndpoint,
payload.llmForm.mainApiKey,
)
self._apply_model_setting(
model_rows["backup"],
payload.llmForm.backupProvider,
payload.llmForm.backupModel,
payload.llmForm.backupEndpoint,
self._apply_model_setting(
model_rows["main"],
payload.llmForm.mainProvider,
payload.llmForm.mainModel,
payload.llmForm.mainEndpoint,
payload.llmForm.mainApiKey,
)
self._apply_model_setting(
model_rows["backup"],
payload.llmForm.backupProvider,
payload.llmForm.backupModel,
payload.llmForm.backupEndpoint,
payload.llmForm.backupApiKey,
)
self._apply_model_setting(
@@ -279,30 +285,60 @@ class SettingsService:
payload.renderForm.jwtSecret,
)
self._replace_secret_if_present(secrets_row, "smtp_password_encrypted", payload.mailForm.password)
hermes_snapshot = capture_hermes_config_snapshot()
try:
sync_hermes_model_settings(
primary_route=self._build_hermes_model_route(model_rows["main"]),
fallback_route=self._build_hermes_model_route(model_rows["backup"]),
)
self.db.add(settings_row)
self.db.add(secrets_row)
for model_row in model_rows.values():
self.db.add(model_row)
self.db.commit()
except Exception:
self.db.rollback()
restore_hermes_config_snapshot(hermes_snapshot)
raise
self.db.refresh(settings_row)
self.db.refresh(secrets_row)
for model_row in model_rows.values():
self.db.refresh(model_row)
return self._serialize(settings_row, secrets_row, model_rows)
self.db.add(settings_row)
self.db.add(secrets_row)
for model_row in model_rows.values():
self.db.add(model_row)
self.db.commit()
self.db.refresh(settings_row)
self.db.refresh(secrets_row)
for model_row in model_rows.values():
self.db.refresh(model_row)
return self._serialize(settings_row, secrets_row, model_rows)
def load_saved_model_api_key(self, slot: str | None) -> str:
if not slot or slot not in MODEL_SLOT_CONFIGS:
return ""
def load_saved_model_api_key(self, slot: str | None) -> str:
if not slot or slot not in MODEL_SLOT_CONFIGS:
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
if not encrypted_value:
return ""
return decrypt_secret(encrypted_value)
return decrypt_secret(encrypted_value)
def get_runtime_model_config(self, slot: str) -> dict[str, str]:
if slot not in MODEL_SLOT_CONFIGS:
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]
return {
"slot": slot,
"provider": model_row.provider,
"model": model_row.model_name,
"endpoint": model_row.endpoint,
"apiKey": self.load_saved_model_api_key(slot),
"capability": model_row.capability,
}
def get_admin_credentials(self) -> AdminCredentialRecord | None:
settings_row, secrets_row = self.ensure_settings_ready()
@@ -448,10 +484,22 @@ class SettingsService:
model_row.model_name = model_name
model_row.endpoint = endpoint
normalized_api_key = api_key.strip()
normalized_api_key = api_key.strip()
if normalized_api_key:
model_row.api_key_encrypted = encrypt_secret(normalized_api_key)
def _build_hermes_model_route(self, model_row: SystemModelSetting) -> HermesModelRoute:
api_key = ""
if model_row.api_key_encrypted:
api_key = decrypt_secret(model_row.api_key_encrypted)
return HermesModelRoute(
provider_label=str(model_row.provider or "").strip(),
model=str(model_row.model_name or "").strip(),
endpoint=str(model_row.endpoint or "").strip(),
api_key=api_key,
)
def _ensure_settings_schema(self) -> None:
bind = self.db.get_bind()
inspector = inspect(bind)