config.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. """配置加载:YAML -> ${ENV} 注入 -> 补默认值 -> 校验。
  2. 设计目标:config.yaml 里只需要按 API 端点分类填模型名,
  3. 端点地址、探测参数、判定字段全部由本文件的 ENDPOINTS 提供默认值。
  4. """
  5. import logging
  6. import os
  7. import re
  8. import yaml
  9. from mailer import normalize_domain
  10. log = logging.getLogger("config")
  11. _ENV_RE = re.compile(r"\$\{([A-Za-z0-9_]+)\}")
  12. # ============================================================
  13. # 按 API 端点分类的默认值
  14. # key 即 config.yaml 中 models 下的分组名,用户只填 models 列表
  15. # ============================================================
  16. ENDPOINTS = {
  17. # 文本对话(OpenAI 兼容模式)
  18. "chat": {
  19. "label": "文本对话",
  20. "url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
  21. "timeout_seconds": 60,
  22. # 最省 token 的探测:只让模型回一个 ok
  23. "prompt": "ok",
  24. "max_tokens": 16,
  25. # qwen3 系列混合推理模型非流式调用需要关闭思考模式;
  26. # 若某模型不接受该参数,把这里清空即可
  27. "extra_params": {"enable_thinking": False},
  28. },
  29. # 文本转语音(WebSocket 协议)
  30. "tts": {
  31. "label": "文本转语音",
  32. "url": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
  33. "timeout_seconds": 30,
  34. # 只建立连接并等 task-started,不发送待合成文本,因此不产生合成用量
  35. "voice": "longxiaochun_v2",
  36. "audio_format": "mp3",
  37. "sample_rate": 22050,
  38. },
  39. # 语音转文本(录音文件识别,异步提交)
  40. "asr": {
  41. "label": "语音转文本",
  42. "url": "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
  43. "timeout_seconds": 60,
  44. # 官方示例音频,只提交任务不轮询结果
  45. "sample_audio_url": (
  46. "https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav"
  47. ),
  48. },
  49. # 文生图(异步提交)
  50. "text2image": {
  51. "label": "文生图",
  52. "url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis",
  53. "timeout_seconds": 60,
  54. "prompt": "a red apple",
  55. "size": "1024*1024",
  56. # validate=不传 prompt,靠服务端"先解析模型再校验参数"的顺序免费判断模型是否在线
  57. # submit=真实提交生成任务(会产生费用)
  58. "probe_mode": "validate",
  59. },
  60. # 文生视频(异步提交)—— 单次调用有真实费用,见 config.yaml 注释
  61. "text2video": {
  62. "label": "文生视频",
  63. "url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
  64. "timeout_seconds": 60,
  65. "prompt": "a red apple on a table",
  66. "size": "832*480",
  67. "duration": 5,
  68. "probe_mode": "validate",
  69. },
  70. }
  71. def expand_env(value):
  72. """递归把字符串中的 ${ENV} 替换为环境变量值(未设置则替换为空串)。"""
  73. if isinstance(value, str):
  74. return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), value)
  75. if isinstance(value, dict):
  76. return {k: expand_env(v) for k, v in value.items()}
  77. if isinstance(value, list):
  78. return [expand_env(v) for v in value]
  79. return value
  80. def load_config(path):
  81. with open(path, "r", encoding="utf-8") as f:
  82. cfg = yaml.safe_load(f) or {}
  83. if not isinstance(cfg, dict):
  84. raise ValueError("配置文件顶层必须是映射(mapping)")
  85. cfg = expand_env(cfg)
  86. _apply_defaults(cfg)
  87. _validate(cfg)
  88. return cfg
  89. def _apply_defaults(cfg):
  90. cfg.setdefault("api_key", "")
  91. cfg.setdefault("interval_minutes", 360)
  92. mail = cfg.setdefault("mail", {})
  93. mail.setdefault("imap_port", 993)
  94. mail.setdefault("inbox_folder", "INBOX")
  95. mail.setdefault("sent_folder", "Sent Messages")
  96. mail.setdefault("smtp_port", 465)
  97. mail.setdefault("smtp_ssl", True)
  98. mail.setdefault("append_to_sent", True)
  99. mail.setdefault("notify_emails", [])
  100. if mail["notify_emails"] is None:
  101. mail["notify_emails"] = []
  102. notice = cfg.setdefault("offline_notice", {})
  103. notice.setdefault("keyword", "模型下线通知")
  104. notice.setdefault("sender_domains", [])
  105. notice.setdefault("lookback_days", 3)
  106. if notice["sender_domains"] is None:
  107. notice["sender_domains"] = []
  108. if isinstance(notice["sender_domains"], str): # 只写一个域名时允许不写成列表
  109. notice["sender_domains"] = [notice["sender_domains"]]
  110. # 兼容旧配置里的 sender(完整邮箱地址):自动取其域名
  111. legacy = (notice.pop("sender", "") or "").strip()
  112. if legacy and not notice["sender_domains"]:
  113. notice["sender_domains"] = [legacy]
  114. notice["sender_domains"] = [
  115. d for d in (normalize_domain(x) for x in notice["sender_domains"]) if d
  116. ]
  117. dt = cfg.setdefault("dingtalk", {})
  118. dt.setdefault("enabled", False)
  119. dt.setdefault("access_token", "")
  120. dt.setdefault("webhook", "")
  121. dt.setdefault("secret", "")
  122. dt.setdefault("at_mobiles", [])
  123. dt.setdefault("at_all", False)
  124. dt.setdefault("timeout_seconds", 15)
  125. # yaml 里写了键但没写条目会解析成 None,统一规范成空列表
  126. if dt["at_mobiles"] is None:
  127. dt["at_mobiles"] = []
  128. # 模型分组:用端点默认值兜底,用户只需提供 models 列表
  129. groups = cfg.setdefault("models", {})
  130. for name, defaults in ENDPOINTS.items():
  131. g = groups.setdefault(name, {})
  132. if not isinstance(g, dict):
  133. raise ValueError(f"models.{name} 必须是映射(mapping)")
  134. g.setdefault("enabled", True)
  135. g.setdefault("models", [])
  136. for k, v in defaults.items():
  137. g.setdefault(k, v)
  138. g["endpoint"] = name
  139. def _validate(cfg):
  140. if not cfg["api_key"]:
  141. raise ValueError("api_key 必填(百炼 DashScope Key,也可用 ${DASHSCOPE_API_KEY} 注入)")
  142. try:
  143. interval = int(cfg["interval_minutes"])
  144. except (TypeError, ValueError):
  145. raise ValueError("interval_minutes 必须是整数(分钟)") from None
  146. if interval < 1:
  147. raise ValueError("interval_minutes 必须 >= 1")
  148. cfg["interval_minutes"] = interval
  149. mail = cfg["mail"]
  150. for key, desc in (
  151. ("imap_host", "IMAP 服务器"),
  152. ("smtp_host", "SMTP 服务器"),
  153. ("username", "邮箱账号"),
  154. ("password", "邮箱密码/授权码"),
  155. ):
  156. if not mail.get(key):
  157. raise ValueError(f"mail.{key} 必填({desc})")
  158. if not mail["notify_emails"]:
  159. raise ValueError("mail.notify_emails 不能为空(告警群发对象)")
  160. if not cfg["offline_notice"]["sender_domains"]:
  161. log.warning(
  162. "offline_notice.sender_domains 为空:模块 A 将不校验发件人,"
  163. "任何人发含关键词的邮件都会触发转发,建议至少填一个域名"
  164. )
  165. dt = cfg["dingtalk"]
  166. if dt.get("enabled"):
  167. has_token = bool((dt.get("access_token") or "").strip())
  168. has_in_url = "access_token=" in (dt.get("webhook") or "")
  169. if not (has_token or has_in_url):
  170. raise ValueError(
  171. "dingtalk.enabled 为 true 时必须配置 access_token"
  172. "(或在 webhook 里自带 access_token)"
  173. )
  174. if not isinstance(dt.get("at_mobiles") or [], list):
  175. raise ValueError("dingtalk.at_mobiles 必须是列表")
  176. unknown = set(cfg["models"]) - set(ENDPOINTS)
  177. if unknown:
  178. raise ValueError(
  179. "models 下存在未知端点分组: %s(可用: %s)"
  180. % (", ".join(sorted(unknown)), ", ".join(ENDPOINTS))
  181. )
  182. for name, g in cfg["models"].items():
  183. if not isinstance(g["models"], list):
  184. raise ValueError(f"models.{name}.models 必须是列表")
  185. def enabled_probe_targets(cfg):
  186. """展开成待探测列表: [(模型名, 分组配置), ...],保持配置顺序。"""
  187. targets = []
  188. for name in ENDPOINTS:
  189. g = cfg["models"].get(name) or {}
  190. if not g.get("enabled"):
  191. continue
  192. for model in g["models"]:
  193. if model:
  194. targets.append((model, g))
  195. return targets