- 新增 Robot.apply_runtime_config 统一刷新邮件发送器、管理员列表与 LLM 运行时缓存\n- 新增 LLMRegistry.invalidate_cache 主动清理目录与 legacy 配置缓存\n- 后台保存全局配置与 LLM 目录后立即应用运行时配置,减少重启依赖
393 lines
16 KiB
Python
393 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
import yaml
|
||
|
||
from db.connection import DBConnectionManager
|
||
|
||
|
||
class LLMRegistry:
|
||
"""统一 LLM 路由注册器(scene -> target -> final_config)。
|
||
|
||
当前支持两套数据源:
|
||
1. 新目录模型(推荐):MySQL 的 provider_templates / dify_apps / backends / scenes;
|
||
2. 旧模型兜底:config.yaml 的 llm(backends/scenes)。
|
||
"""
|
||
|
||
_cache: Dict[str, Any] = {
|
||
"cache_until": 0.0,
|
||
"catalog": {},
|
||
"legacy_llm": {},
|
||
}
|
||
|
||
@classmethod
|
||
def invalidate_cache(cls) -> None:
|
||
"""主动清空运行时缓存。
|
||
|
||
说明:
|
||
1. 后台修改全局 YAML 或 MySQL 中的 LLM 目录后,旧缓存可能还在 3 秒有效期内;
|
||
2. 对于“保存后立刻生效”的后台体验,主动失效比等待 TTL 自然过期更直接;
|
||
3. 这里只清缓存,不做任何 IO,下一次 resolve/get_catalog 时会自动重新装载最新配置。
|
||
"""
|
||
cls._cache = {
|
||
"cache_until": 0.0,
|
||
"catalog": {},
|
||
"legacy_llm": {},
|
||
}
|
||
|
||
@classmethod
|
||
def get_root_config_path(cls) -> Path:
|
||
return Path(__file__).resolve().parents[2] / "config.yaml"
|
||
|
||
@classmethod
|
||
def _load_yaml_root(cls) -> Dict[str, Any]:
|
||
"""读取 YAML 根配置(仅作为迁移兜底)。"""
|
||
path = cls.get_root_config_path()
|
||
if not path.exists():
|
||
return {}
|
||
with open(path, "r", encoding="utf-8") as fp:
|
||
return yaml.safe_load(fp) or {}
|
||
|
||
@classmethod
|
||
def _load_yaml_legacy_llm(cls) -> Dict[str, Any]:
|
||
"""读取旧 llm 结构(backends/scenes)作为回退。"""
|
||
root = cls._load_yaml_root()
|
||
llm = root.get("llm", {}) or {}
|
||
return llm if isinstance(llm, dict) else {}
|
||
|
||
@staticmethod
|
||
def _loads_json(value: Any, default: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
"""把 JSON 字段安全转为 dict。"""
|
||
if isinstance(value, dict):
|
||
return value
|
||
if isinstance(value, str):
|
||
try:
|
||
obj = json.loads(value)
|
||
return obj if isinstance(obj, dict) else (default or {})
|
||
except json.JSONDecodeError:
|
||
return default or {}
|
||
return default or {}
|
||
|
||
@classmethod
|
||
def _load_catalog_from_mysql(cls) -> Dict[str, Any]:
|
||
"""从 MySQL 读取新目录模型。"""
|
||
try:
|
||
db_manager = DBConnectionManager.get_instance()
|
||
if not db_manager or not db_manager.mysql_pool:
|
||
return {}
|
||
|
||
conn = db_manager.get_mysql_connection()
|
||
try:
|
||
with conn.cursor(dictionary=True) as cursor:
|
||
cursor.execute("SELECT name, provider_type, config_json, enabled FROM t_llm_provider_templates")
|
||
provider_rows = cursor.fetchall() or []
|
||
|
||
cursor.execute(
|
||
"""
|
||
SELECT name, provider_template, app_key, workflow_output_key, config_json, enabled
|
||
FROM t_llm_dify_apps
|
||
"""
|
||
)
|
||
app_rows = cursor.fetchall() or []
|
||
|
||
cursor.execute("SELECT name, config_json, enabled FROM t_llm_backends")
|
||
backend_rows = cursor.fetchall() or []
|
||
|
||
cursor.execute("SELECT name, target_type, target_ref, enabled FROM t_llm_scenes")
|
||
scene_rows = cursor.fetchall() or []
|
||
|
||
cursor.execute(
|
||
"SELECT meta_value FROM t_llm_catalog_meta WHERE meta_key='default_scene' LIMIT 1"
|
||
)
|
||
meta_row = cursor.fetchone() or {}
|
||
finally:
|
||
conn.close()
|
||
|
||
providers = {
|
||
str(row.get("name") or "").strip(): {
|
||
"name": str(row.get("name") or "").strip(),
|
||
"provider_type": str(row.get("provider_type") or "").strip().lower(),
|
||
"enabled": bool(int(row.get("enabled") or 0)),
|
||
"config": cls._loads_json(row.get("config_json"), {}),
|
||
}
|
||
for row in provider_rows
|
||
if str(row.get("name") or "").strip()
|
||
}
|
||
|
||
dify_apps = {
|
||
str(row.get("name") or "").strip(): {
|
||
"name": str(row.get("name") or "").strip(),
|
||
"provider_template": str(row.get("provider_template") or "").strip(),
|
||
"app_key": str(row.get("app_key") or "").strip(),
|
||
"workflow_output_key": str(row.get("workflow_output_key") or "text").strip(),
|
||
"enabled": bool(int(row.get("enabled") or 0)),
|
||
"config": cls._loads_json(row.get("config_json"), {}),
|
||
}
|
||
for row in app_rows
|
||
if str(row.get("name") or "").strip()
|
||
}
|
||
|
||
backends = {
|
||
str(row.get("name") or "").strip(): {
|
||
"name": str(row.get("name") or "").strip(),
|
||
"enabled": bool(int(row.get("enabled") or 0)),
|
||
"config": cls._loads_json(row.get("config_json"), {}),
|
||
}
|
||
for row in backend_rows
|
||
if str(row.get("name") or "").strip()
|
||
}
|
||
|
||
scenes = {
|
||
str(row.get("name") or "").strip(): {
|
||
"name": str(row.get("name") or "").strip(),
|
||
"target_type": str(row.get("target_type") or "").strip().lower(),
|
||
"target_ref": str(row.get("target_ref") or "").strip(),
|
||
"enabled": bool(int(row.get("enabled") or 0)),
|
||
}
|
||
for row in scene_rows
|
||
if str(row.get("name") or "").strip()
|
||
}
|
||
|
||
catalog = {
|
||
"default_scene": str(meta_row.get("meta_value") or "").strip(),
|
||
"providers": providers,
|
||
"dify_apps": dify_apps,
|
||
"backends": backends,
|
||
"scenes": scenes,
|
||
}
|
||
# 只要目录中存在场景,就认为新模型可用。
|
||
return catalog if scenes else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
@classmethod
|
||
def _load_runtime_snapshot(cls) -> Dict[str, Any]:
|
||
"""加载运行时快照(目录模型优先,legacy 兜底)。"""
|
||
now = time.time()
|
||
if cls._cache.get("cache_until", 0.0) > now and cls._cache.get("catalog") is not None:
|
||
return {
|
||
"catalog": cls._cache.get("catalog", {}) or {},
|
||
"legacy_llm": cls._cache.get("legacy_llm", {}) or {},
|
||
}
|
||
|
||
catalog = cls._load_catalog_from_mysql()
|
||
legacy_llm = cls._load_yaml_legacy_llm()
|
||
cls._cache = {
|
||
"cache_until": now + 3.0,
|
||
"catalog": catalog or {},
|
||
"legacy_llm": legacy_llm or {},
|
||
}
|
||
return {"catalog": catalog or {}, "legacy_llm": legacy_llm or {}}
|
||
|
||
@classmethod
|
||
def _merge_dify_provider_and_app(cls, provider: Dict[str, Any], app: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""把 Dify Provider 模板与 Dify 应用差异合并成最终调用配置。"""
|
||
provider_cfg = dict((provider or {}).get("config", {}) or {})
|
||
app_cfg = dict((app or {}).get("config", {}) or {})
|
||
merged = dict(provider_cfg)
|
||
merged.update(app_cfg)
|
||
|
||
# 强制写入 Dify 必要字段:provider、api_key、workflow_output_key。
|
||
merged["provider"] = "dify"
|
||
merged["api_key"] = str((app or {}).get("app_key") or "").strip()
|
||
merged["workflow_output_key"] = str((app or {}).get("workflow_output_key") or "text").strip()
|
||
return merged
|
||
|
||
@classmethod
|
||
def _resolve_scene_with_catalog(cls, scene_name: str, catalog: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""按新目录模型解析 scene。"""
|
||
scenes = (catalog or {}).get("scenes", {}) or {}
|
||
scene = scenes.get(scene_name, {}) or {}
|
||
if not scene or not scene.get("enabled", True):
|
||
return {}
|
||
|
||
target_type = str(scene.get("target_type") or "").strip().lower()
|
||
target_ref = str(scene.get("target_ref") or "").strip()
|
||
if not target_ref:
|
||
return {}
|
||
|
||
if target_type == "backend":
|
||
backend = ((catalog or {}).get("backends", {}) or {}).get(target_ref, {}) or {}
|
||
if not backend or not backend.get("enabled", True):
|
||
return {}
|
||
config = dict(backend.get("config", {}) or {})
|
||
config["backend"] = target_ref
|
||
config["scene"] = scene_name
|
||
return config
|
||
|
||
if target_type == "dify_app":
|
||
app = ((catalog or {}).get("dify_apps", {}) or {}).get(target_ref, {}) or {}
|
||
if not app or not app.get("enabled", True):
|
||
return {}
|
||
provider_name = str(app.get("provider_template") or "").strip()
|
||
provider = ((catalog or {}).get("providers", {}) or {}).get(provider_name, {}) or {}
|
||
if not provider or not provider.get("enabled", True):
|
||
return {}
|
||
config = cls._merge_dify_provider_and_app(provider, app)
|
||
# 生成稳定 backend 标识,便于日志追踪。
|
||
config["backend"] = f"dify_app::{target_ref}"
|
||
config["scene"] = scene_name
|
||
return config
|
||
|
||
return {}
|
||
|
||
@classmethod
|
||
def _resolve_scene_with_legacy(cls, scene_name: str, legacy_llm: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""按旧 llm(backends/scenes) 结构解析 scene(兜底)。"""
|
||
llm = legacy_llm or {}
|
||
backends = llm.get("backends", {}) or {}
|
||
scenes = llm.get("scenes", {}) or {}
|
||
if not isinstance(backends, dict):
|
||
backends = {}
|
||
if not isinstance(scenes, dict):
|
||
scenes = {}
|
||
backend_name = str(scenes.get(scene_name) or "").strip()
|
||
if not backend_name:
|
||
backend_name = str(llm.get("default_backend") or "").strip()
|
||
if not backend_name:
|
||
return {}
|
||
backend = backends.get(backend_name, {}) or {}
|
||
if not isinstance(backend, dict):
|
||
return {}
|
||
config = dict(backend)
|
||
config["backend"] = backend_name
|
||
config["scene"] = scene_name
|
||
return config
|
||
|
||
@classmethod
|
||
def get_catalog(cls) -> Dict[str, Any]:
|
||
"""对外暴露运行时目录(优先 MySQL)。"""
|
||
return cls._load_runtime_snapshot().get("catalog", {}) or {}
|
||
|
||
@classmethod
|
||
def get_llm_config(cls) -> Dict[str, Any]:
|
||
"""兼容输出:将目录模型转为 legacy llm(backends/scenes) 视图。
|
||
|
||
说明:
|
||
1. 若目录模型可用,则返回“展开后”的 backends/scenes;
|
||
2. 若目录模型不可用,则原样返回 YAML 旧结构。
|
||
"""
|
||
snapshot = cls._load_runtime_snapshot()
|
||
catalog = snapshot.get("catalog", {}) or {}
|
||
if not catalog:
|
||
llm = snapshot.get("legacy_llm", {}) or {}
|
||
return llm if isinstance(llm, dict) else {}
|
||
|
||
backends: Dict[str, Any] = {}
|
||
scenes_map: Dict[str, str] = {}
|
||
|
||
for backend_name, backend in (catalog.get("backends", {}) or {}).items():
|
||
if not (backend or {}).get("enabled", True):
|
||
continue
|
||
backends[backend_name] = dict((backend or {}).get("config", {}) or {})
|
||
|
||
for app_name, app in (catalog.get("dify_apps", {}) or {}).items():
|
||
if not (app or {}).get("enabled", True):
|
||
continue
|
||
provider_name = str((app or {}).get("provider_template") or "").strip()
|
||
provider = ((catalog.get("providers", {}) or {}).get(provider_name, {}) or {})
|
||
if not provider or not provider.get("enabled", True):
|
||
continue
|
||
synthetic_backend = f"dify_app::{app_name}"
|
||
backends[synthetic_backend] = cls._merge_dify_provider_and_app(provider, app)
|
||
|
||
for scene_name, scene in (catalog.get("scenes", {}) or {}).items():
|
||
if not (scene or {}).get("enabled", True):
|
||
continue
|
||
target_type = str((scene or {}).get("target_type") or "").strip().lower()
|
||
target_ref = str((scene or {}).get("target_ref") or "").strip()
|
||
if not target_ref:
|
||
continue
|
||
if target_type == "dify_app":
|
||
scenes_map[scene_name] = f"dify_app::{target_ref}"
|
||
elif target_type == "backend":
|
||
scenes_map[scene_name] = target_ref
|
||
|
||
default_scene = str(catalog.get("default_scene") or "").strip()
|
||
default_backend = str(scenes_map.get(default_scene) or "").strip()
|
||
return {
|
||
"default_backend": default_backend,
|
||
"backends": backends,
|
||
"scenes": scenes_map,
|
||
}
|
||
|
||
@classmethod
|
||
def get_default_backend(cls) -> str:
|
||
llm_config = cls.get_llm_config()
|
||
return str(llm_config.get("default_backend", "") or "").strip()
|
||
|
||
@classmethod
|
||
def get_backend(cls, backend_name: str) -> Dict[str, Any]:
|
||
if not backend_name:
|
||
return {}
|
||
llm_config = cls.get_llm_config()
|
||
backends = llm_config.get("backends", {}) or {}
|
||
backend = backends.get(backend_name, {}) or {}
|
||
return dict(backend) if isinstance(backend, dict) else {}
|
||
|
||
@classmethod
|
||
def get_scenes(cls) -> Dict[str, str]:
|
||
llm_config = cls.get_llm_config()
|
||
raw_scenes = llm_config.get("scenes", {}) or {}
|
||
if not isinstance(raw_scenes, dict):
|
||
return {}
|
||
normalized: Dict[str, str] = {}
|
||
for raw_scene, raw_backend in raw_scenes.items():
|
||
scene_name = str(raw_scene or "").strip()
|
||
backend_name = str(raw_backend or "").strip()
|
||
if scene_name and backend_name:
|
||
normalized[scene_name] = backend_name
|
||
return normalized
|
||
|
||
@classmethod
|
||
def get_scene_backend_name(cls, scene_name: str) -> str:
|
||
name = str(scene_name or "").strip()
|
||
if not name:
|
||
return cls.get_default_backend()
|
||
scenes = cls.get_scenes()
|
||
backend_name = str(scenes.get(name, "") or "").strip()
|
||
if backend_name:
|
||
return backend_name
|
||
return cls.get_default_backend()
|
||
|
||
@classmethod
|
||
def resolve_by_scene(cls, scene_name: str) -> Dict[str, Any]:
|
||
"""按 scene 解析最终配置(优先目录模型)。"""
|
||
name = str(scene_name or "").strip()
|
||
if not name:
|
||
return {}
|
||
snapshot = cls._load_runtime_snapshot()
|
||
catalog = snapshot.get("catalog", {}) or {}
|
||
if catalog:
|
||
resolved = cls._resolve_scene_with_catalog(name, catalog)
|
||
if resolved:
|
||
return resolved
|
||
return cls._resolve_scene_with_legacy(name, snapshot.get("legacy_llm", {}) or {})
|
||
|
||
@classmethod
|
||
def resolve(cls, local_config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||
local = dict(local_config or {})
|
||
scene_name = str(local.get("scene") or "").strip()
|
||
if scene_name:
|
||
merged = cls.resolve_by_scene(scene_name)
|
||
merged.update(local)
|
||
# 以场景路由结果为准,避免调用方覆盖关键路由字段。
|
||
routed = cls.resolve_by_scene(scene_name)
|
||
if routed:
|
||
merged.update(routed)
|
||
merged["scene"] = scene_name
|
||
return merged
|
||
return local
|
||
|
||
@classmethod
|
||
def get_scene_names(cls) -> List[str]:
|
||
"""返回可用场景名列表(便于后台下拉框或校验)。"""
|
||
catalog = cls.get_catalog()
|
||
if catalog:
|
||
return sorted(list((catalog.get("scenes", {}) or {}).keys()))
|
||
return sorted(list(cls.get_scenes().keys()))
|