| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 |
- """配置加载:YAML -> ${ENV} 注入 -> 补默认值 -> 校验。
- 设计目标:config.yaml 里只需要按 API 端点分类填模型名,
- 端点地址、探测参数、判定字段全部由本文件的 ENDPOINTS 提供默认值。
- """
- import logging
- import os
- import re
- import yaml
- from mailer import normalize_domain
- log = logging.getLogger("config")
- _ENV_RE = re.compile(r"\$\{([A-Za-z0-9_]+)\}")
- # ============================================================
- # 按 API 端点分类的默认值
- # key 即 config.yaml 中 models 下的分组名,用户只填 models 列表
- # ============================================================
- ENDPOINTS = {
- # 文本对话(OpenAI 兼容模式)
- "chat": {
- "label": "文本对话",
- "url": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
- "timeout_seconds": 60,
- # 最省 token 的探测:只让模型回一个 ok
- "prompt": "ok",
- "max_tokens": 16,
- # qwen3 系列混合推理模型非流式调用需要关闭思考模式;
- # 若某模型不接受该参数,把这里清空即可
- "extra_params": {"enable_thinking": False},
- },
- # 文本转语音(WebSocket 协议)
- "tts": {
- "label": "文本转语音",
- "url": "wss://dashscope.aliyuncs.com/api-ws/v1/inference/",
- "timeout_seconds": 30,
- # 只建立连接并等 task-started,不发送待合成文本,因此不产生合成用量
- "voice": "longxiaochun_v2",
- "audio_format": "mp3",
- "sample_rate": 22050,
- },
- # 语音转文本(录音文件识别,异步提交)
- "asr": {
- "label": "语音转文本",
- "url": "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
- "timeout_seconds": 60,
- # 官方示例音频,只提交任务不轮询结果
- "sample_audio_url": (
- "https://dashscope.oss-cn-beijing.aliyuncs.com/samples/audio/paraformer/hello_world_female2.wav"
- ),
- },
- # 文生图(异步提交)
- "text2image": {
- "label": "文生图",
- "url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis",
- "timeout_seconds": 60,
- "prompt": "a red apple",
- "size": "1024*1024",
- # validate=不传 prompt,靠服务端"先解析模型再校验参数"的顺序免费判断模型是否在线
- # submit=真实提交生成任务(会产生费用)
- "probe_mode": "validate",
- },
- # 文生视频(异步提交)—— 单次调用有真实费用,见 config.yaml 注释
- "text2video": {
- "label": "文生视频",
- "url": "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis",
- "timeout_seconds": 60,
- "prompt": "a red apple on a table",
- "size": "832*480",
- "duration": 5,
- "probe_mode": "validate",
- },
- }
- def expand_env(value):
- """递归把字符串中的 ${ENV} 替换为环境变量值(未设置则替换为空串)。"""
- if isinstance(value, str):
- return _ENV_RE.sub(lambda m: os.environ.get(m.group(1), ""), value)
- if isinstance(value, dict):
- return {k: expand_env(v) for k, v in value.items()}
- if isinstance(value, list):
- return [expand_env(v) for v in value]
- return value
- def load_config(path):
- with open(path, "r", encoding="utf-8") as f:
- cfg = yaml.safe_load(f) or {}
- if not isinstance(cfg, dict):
- raise ValueError("配置文件顶层必须是映射(mapping)")
- cfg = expand_env(cfg)
- _apply_defaults(cfg)
- _validate(cfg)
- return cfg
- def _apply_defaults(cfg):
- cfg.setdefault("api_key", "")
- cfg.setdefault("interval_minutes", 360)
- mail = cfg.setdefault("mail", {})
- mail.setdefault("imap_port", 993)
- mail.setdefault("inbox_folder", "INBOX")
- mail.setdefault("sent_folder", "Sent Messages")
- mail.setdefault("smtp_port", 465)
- mail.setdefault("smtp_ssl", True)
- mail.setdefault("append_to_sent", True)
- mail.setdefault("notify_emails", [])
- if mail["notify_emails"] is None:
- mail["notify_emails"] = []
- notice = cfg.setdefault("offline_notice", {})
- notice.setdefault("keyword", "模型下线通知")
- notice.setdefault("sender_domains", [])
- notice.setdefault("lookback_days", 3)
- if notice["sender_domains"] is None:
- notice["sender_domains"] = []
- if isinstance(notice["sender_domains"], str): # 只写一个域名时允许不写成列表
- notice["sender_domains"] = [notice["sender_domains"]]
- # 兼容旧配置里的 sender(完整邮箱地址):自动取其域名
- legacy = (notice.pop("sender", "") or "").strip()
- if legacy and not notice["sender_domains"]:
- notice["sender_domains"] = [legacy]
- notice["sender_domains"] = [
- d for d in (normalize_domain(x) for x in notice["sender_domains"]) if d
- ]
- dt = cfg.setdefault("dingtalk", {})
- dt.setdefault("enabled", False)
- dt.setdefault("access_token", "")
- dt.setdefault("webhook", "")
- dt.setdefault("secret", "")
- dt.setdefault("at_mobiles", [])
- dt.setdefault("at_all", False)
- dt.setdefault("timeout_seconds", 15)
- # yaml 里写了键但没写条目会解析成 None,统一规范成空列表
- if dt["at_mobiles"] is None:
- dt["at_mobiles"] = []
- # 模型分组:用端点默认值兜底,用户只需提供 models 列表
- groups = cfg.setdefault("models", {})
- for name, defaults in ENDPOINTS.items():
- g = groups.setdefault(name, {})
- if not isinstance(g, dict):
- raise ValueError(f"models.{name} 必须是映射(mapping)")
- g.setdefault("enabled", True)
- g.setdefault("models", [])
- for k, v in defaults.items():
- g.setdefault(k, v)
- g["endpoint"] = name
- def _validate(cfg):
- if not cfg["api_key"]:
- raise ValueError("api_key 必填(百炼 DashScope Key,也可用 ${DASHSCOPE_API_KEY} 注入)")
- try:
- interval = int(cfg["interval_minutes"])
- except (TypeError, ValueError):
- raise ValueError("interval_minutes 必须是整数(分钟)") from None
- if interval < 1:
- raise ValueError("interval_minutes 必须 >= 1")
- cfg["interval_minutes"] = interval
- mail = cfg["mail"]
- for key, desc in (
- ("imap_host", "IMAP 服务器"),
- ("smtp_host", "SMTP 服务器"),
- ("username", "邮箱账号"),
- ("password", "邮箱密码/授权码"),
- ):
- if not mail.get(key):
- raise ValueError(f"mail.{key} 必填({desc})")
- if not mail["notify_emails"]:
- raise ValueError("mail.notify_emails 不能为空(告警群发对象)")
- if not cfg["offline_notice"]["sender_domains"]:
- log.warning(
- "offline_notice.sender_domains 为空:模块 A 将不校验发件人,"
- "任何人发含关键词的邮件都会触发转发,建议至少填一个域名"
- )
- dt = cfg["dingtalk"]
- if dt.get("enabled"):
- has_token = bool((dt.get("access_token") or "").strip())
- has_in_url = "access_token=" in (dt.get("webhook") or "")
- if not (has_token or has_in_url):
- raise ValueError(
- "dingtalk.enabled 为 true 时必须配置 access_token"
- "(或在 webhook 里自带 access_token)"
- )
- if not isinstance(dt.get("at_mobiles") or [], list):
- raise ValueError("dingtalk.at_mobiles 必须是列表")
- unknown = set(cfg["models"]) - set(ENDPOINTS)
- if unknown:
- raise ValueError(
- "models 下存在未知端点分组: %s(可用: %s)"
- % (", ".join(sorted(unknown)), ", ".join(ENDPOINTS))
- )
- for name, g in cfg["models"].items():
- if not isinstance(g["models"], list):
- raise ValueError(f"models.{name}.models 必须是列表")
- def enabled_probe_targets(cfg):
- """展开成待探测列表: [(模型名, 分组配置), ...],保持配置顺序。"""
- targets = []
- for name in ENDPOINTS:
- g = cfg["models"].get(name) or {}
- if not g.get("enabled"):
- continue
- for model in g["models"]:
- if model:
- targets.append((model, g))
- return targets
|