Ver código fonte

first commit

关习习 2 semanas atrás
commit
f78825b0ef
9 arquivos alterados com 2068 adições e 0 exclusões
  1. 266 0
      README.md
  2. 220 0
      config.py
  3. 104 0
      config.yaml.example
  4. 407 0
      docs/index.html
  5. 434 0
      mailer.py
  6. 170 0
      main.py
  7. 159 0
      notifier.py
  8. 305 0
      probes.py
  9. 3 0
      requirements.txt

+ 266 - 0
README.md

@@ -0,0 +1,266 @@
+# model-watchdog
+
+阿里云百炼(DashScope)**模型下线通知** + **模型可用性**双模块监控。
+一个常驻 loop,按 `interval_minutes` 反复执行两个检测,命中即"邮件 + 钉钉"双重告警。
+
+```
+main.py loop(每 interval_minutes 一轮)
+├── 模块 A  check_offline_notice   检测到下线通知 -> 转发原邮件给通知列表 + 钉钉
+└── 模块 B  check_model_health     模型可用性检测 -> 聚合告警邮件 + 钉钉
+```
+
+## 快速开始
+
+```bash
+pip install -r requirements.txt
+
+python main.py --once --dry-run     # 只检测并打印将发送的告警,不真发
+python main.py --once              # 真实跑一轮
+python main.py                     # 常驻循环
+```
+
+命令行参数:
+
+| 参数 | 说明 |
+|---|---|
+| `-c, --config` | 配置文件路径,默认 `config.yaml` |
+| `--once` | 只执行一轮后退出 |
+| `--dry-run` | 只检测并打印告警内容,不真正发送 |
+| `--skip-mail` | 跳过模块 A(只测模型) |
+| `--skip-probe` | 跳过模块 B(只测邮箱) |
+| `--test-dingtalk` | 只给钉钉发一条测试消息后退出 |
+
+## 重点配置
+
+1. **`offline_notice.sender_domains`** —— 允许触发告警的发件人**域名**白名单。
+
+2. **`api_key`** —— 百炼 DashScope Key,所有端点探测共用这一个。
+
+## 配置说明
+
+```yaml
+api_key: "sk-xxx"              # 百炼 Key,所有端点共用
+interval_minutes: 360          # 每隔多少分钟跑一轮(360 = 6 小时)
+
+mail:
+  imap_host: "imap.exmail.qq.com"
+  imap_port: 993
+  inbox_folder: "INBOX"
+  sent_folder: "Sent Messages" # 填错会自动在常见候选名里兜底探测
+  smtp_host: "smtp.exmail.qq.com"
+  smtp_port: 465
+  smtp_ssl: true
+  username: "cat@example.com"  # 监控账号,同时是发件人
+  password: "xxx"
+  append_to_sent: true         # 服务商不自动归档时,用 IMAP APPEND 补一份
+  notify_emails:               # 告警群发列表
+    - "someone@example.com"
+
+offline_notice:
+  keyword: "模型下线通知"        # 标题或正文命中即算
+  sender_domains:              # 发件人域名白名单(不是完整地址)
+    - "aliyun.com"
+  lookback_days: 3             # 收件箱与已发送的回溯天数
+
+models:                        # 只填模型名,端点参数见 config.py 的 ENDPOINTS
+  chat:       { enabled: true, models: ["qwen3.5-plus", "qwen3.5-flash"] }
+  tts:        { enabled: true, voice: "longxiaochun_v2", models: ["cosyvoice-v3-flash"] }
+  asr:        { enabled: true, models: ["fun-asr"] }
+  text2image: { enabled: true, probe_mode: "validate", models: ["wan2.2-t2i-flash"] }
+  text2video: { enabled: true, probe_mode: "validate", models: ["wanx2.1-t2v-turbo"] }
+
+dingtalk:
+  enabled: false
+  access_token: "xxx"              # 机器人 token,只填这个即可
+  secret: "SECxxx"                 # 安全设置选"加签"时填,否则留空
+  webhook: ""                      # 留空用官方地址;也可粘整条自带 token 的 webhook
+  at_all: false                    # true = @所有人
+  timeout_seconds: 15
+  at_mobiles:                      # 需要 @ 的成员手机号
+    # - "13800000000"
+```
+
+`enabled: false` 的分组整组跳过;分组下写与 `ENDPOINTS` 同名的字段即可覆盖默认值
+(如 `timeout_seconds`、`prompt`、`voice`、`probe_mode`)。
+所有字段都支持 `${ENV}` 注入,例如 `access_token: "${DINGTALK_DEV_NOTIFY_TOKEN}"`。
+
+### 钉钉机器人
+
+固定调用 `https://oapi.dingtalk.com/robot/send`,`access_token` / `timestamp` / `sign`
+作为 query 参数传入,消息类型为 markdown。
+
+- **加签**:`sign = base64(HMAC-SHA256(secret, "{毫秒时间戳}\n{secret}"))`,
+  由 requests 的 `params` 负责 URL 编码 —— 不要自己 `quote`,二次编码会导致验签失败。
+- **@成员**:`at_mobiles` 里的号码会同时写进 `at.atMobiles` **并追加到正文末尾**,
+  因为钉钉要求 markdown 正文里出现 `@手机号` 字面量,@ 才会真的生效。
+- `enabled: true` 但没配 `access_token`(且 webhook 里也没有)时,`load_config` 会直接报错退出,
+  避免"以为配好了、其实一直静默不推送"。
+- 钉钉返回 `errcode != 0` 时抛异常,`errmsg` 会原样打进日志(token 无效 / 验签失败 /
+  关键词不匹配 / 限流都能从这里看出来)。
+
+单独联调机器人,不用等整轮检测:
+
+```bash
+python main.py --test-dingtalk
+```
+
+## 模块 A:模型下线通知检测 → 转发
+
+判定条件(两者同时满足):
+
+- 近 `lookback_days`(默认 3)天**收件箱**内
+- 发件人域名属于 `offline_notice.sender_domains`,且标题或正文包含 `offline_notice.keyword`(默认「模型下线通知」)
+
+### 为什么校验域名而不是完整地址
+
+阿里云用哪个具体邮箱号发通知是不确定的,写死完整地址一旦对不上就会**静默漏消息**——
+这是最危险的失效方式。改成域名白名单后,该域名下换任何邮箱号发都能收到,
+同时又拦住了外部陌生人发含关键词的骚扰邮件。
+
+匹配规则是「域名相等**或**为其子域」,不是裸 `endswith`:
+
+| 发件人 | `aliyun.com` 是否命中 |
+|---|---|
+| `noreply@aliyun.com` | ✅ 本域 |
+| `service@mail.aliyun.com` | ✅ 子域 |
+| `evil@notaliyun.com` | ❌ 裸 `endswith` 会误放行 |
+| `evil@fake-aliyun.com` | ❌ 同上 |
+| `evil@aliyun.com.evil.cn` | ❌ 域名前缀伪装 |
+| `aliyun.com@qq.com` | ❌ 目标域塞进本地部分 |
+
+配置写法有容错:`aliyun.com`、`@aliyun.com`、`noreply@aliyun.com`、`ALIYUN.COM`
+都会被规整成 `aliyun.com`;只有一个域名时也可以不写成列表。
+留空则不校验发件人,启动时会打 WARNING。
+
+命中后**立即把原邮件转发**给 `notify_emails`,并同时推钉钉。转发件标题:
+
+```
+【模型下线通知转发】<原邮件标题>
+```
+
+转发件结构:
+
+| 部分 | 内容 |
+|---|---|
+| `text/plain` | 转发说明(原发件人/时间/Message-ID)+ 原邮件正文,可直接阅读 |
+| `message/rfc822` | 原始邮件完整存档 `original.eml`,保留全部头信息 |
+
+`Reply-To` 设为原发件人,直接回复即可回到阿里云。
+
+**去重(本地无状态):按「这封是否已经转发过」判定,依据写在信头里。**
+
+转发时把原邮件的 Message-ID 写进转发件的两个信头:
+
+| 信头 | 作用 |
+|---|---|
+| `References` | 标准转发/回复关系头 |
+| `X-Forwarded-Msgid` | 自定义头,冗余一份,防止服务商改写 `References` |
+
+每轮检测先扫「已发送」近 N 天,用
+`BODY.PEEK[HEADER.FIELDS (REFERENCES X-FORWARDED-MSGID)]` 批量取这两个头,
+解析成「已转发过的原邮件 Message-ID 集合」。收件箱里命中的通知若其 Message-ID
+已在集合中,说明**这封已经转发过**,跳过。
+
+标题不参与去重,所以改标题不影响去重;但**不要手工删除或改写转发件的这两个信头**。
+
+发信后程序会回查「已发送」确认转发关系已入库(轮询重试 4×4s,因为服务商自动归档有延迟);
+若始终查不到,则用 IMAP APPEND 补一份(`mail.append_to_sent: true`)。
+腾讯企业邮实测为自动归档、不接受 APPEND,且**会完整保留上述两个信头**,靠自动归档即可去重。
+
+另外,程序会跳过发件人等于自己账号的邮件,避免转发件进自己收件箱后自我触发。
+
+IMAP 的 `FROM` 只是子串匹配,仅用来缩小 fetch 范围;取回邮件后会再做一次精确的域名归属校验。
+极少数邮件没有 `Message-ID`,则用「发件人 + 日期 + 标题」生成一个稳定标记参与去重。
+
+## 模块 B:模型可用性检测
+
+**按 API 端点分类,你只需要往对应分组的 `models` 里填模型名**,
+端点地址和探测参数都在 `config.py` 的 `ENDPOINTS` 里有默认值,需要覆盖时在分组下写同名字段。
+
+| 分组 | 告警中显示名 | 端点 | 探测方式 | 费用 |
+|---|---|---|---|---|
+| `chat` | 文本对话 | `/compatible-mode/v1/chat/completions` | prompt 只发 `ok`,`max_tokens=16` | 约 30 token |
+| `tts` | 文本转语音 | `wss://.../api-ws/v1/inference/` | 建连 + `run-task`,收到 `task-started` 立即断开,不发合成文本 | 无 |
+| `asr` | 语音转文本 | `/api/v1/services/audio/asr/transcription` | 异步提交官方示例音频,拿到 `task_id` 即通过,不轮询 | 极低 |
+| `text2image` | 文生图 | `/api/v1/services/aigc/text2image/image-synthesis` | 见下方 `probe_mode` | 0(validate) |
+| `text2video` | 文生视频 | `/api/v1/services/aigc/video-generation/video-synthesis` | 见下方 `probe_mode` | 0(validate) |
+
+### probe_mode(文生图 / 文生视频)
+
+阿里云对这两个端点是**先解析模型、再校验参数**的顺序,据此可以零成本判断模型是否在线:
+
+- `validate`(默认,推荐):故意不传 `prompt`。
+  - 模型在线 → `400 InvalidParameter: input.prompt should not be null` → 判为**可用**
+  - 模型下线 → `400 InvalidParameter: Model not exist.` → 判为**不可用**
+  - 不会真的出图/出视频,**零生成费用**
+- `submit`:真实提交生成任务,端到端验证,**会产生生成费用**(视频尤其贵)
+
+### 告警格式
+
+邮件标题:
+
+```
+【模型可用性告警】3 个模型不可用:deepseek-v4、cosyvoice-v9-flash、wan9.9-t2i-flash
+```
+
+邮件正文:
+
+```
+【模型可用性告警】
+
+检测时间: 2026-09-01 11:10:01
+探测总数: 4 失败: 3
+
+deepseek-v4(文本对话)报错 model_not_found The model `deepseek-v4` does not exist...,完整响应 {...};
+cosyvoice-v9-flash(文本转语音)报错 ModelNotFound Model not found (...)!,完整响应 {...};
+wan9.9-t2i-flash(文生图)报错 InvalidParameter Model not exist.,完整响应 {...}
+
+—— model-watchdog 自动告警
+```
+
+单条格式为 `模型名(显示名)报错 <code> <message>,完整响应 <raw>`,多条之间用 `;\n` 连接。
+本轮所有失败模型聚合为**一条**告警,`完整响应` 保留原始响应体(最长 1500 字符)。
+全部模型正常时不发任何通知(静默)。
+
+## 运行日志
+
+正常一轮(无告警):
+
+```
+配置加载完成: 每 3 分钟一轮 | 待测模型 文本对话×2、文本转语音×1、语音转文本×1、文生图×1、文生视频×1 | 邮箱 cat@mail.gxx12138.space
+下线通知匹配条件: 发件人域名=aliyun.com 关键词=模型下线通知 回溯=3 天
+========== 开始第 2026-09-01 12:13:41 轮检测 ==========
+[邮件] 已发送中近 3 天的转发记录: 2 封原邮件
+[邮件] 收件箱近 3 天命中下线通知: 1 封
+[邮件] 其中 1 封已转发过,跳过
+[模块A] 无需要转发的模型下线通知
+[探测] qwen3.5-plus (文本对话) 正常, 1456 ms
+...
+[模块B] 探测 6 个模型,失败 0 个
+========== 本轮结束,耗时 3.4s,转发下线通知 0 条,不可用模型 0 个 ==========
+休眠 3 分钟,下次检测 2026-09-01 12:16:44
+```
+
+其中「已转发过,跳过」就是去重生效的标志。首次转发时会看到:
+
+```
+[邮件] 已转发下线通知给 1 人: 【模型下线通知转发】xxx
+[邮件] 转发记录已确认在已发送中(服务商自动归档),去重生效
+```
+
+## 文件结构
+
+| 文件 | 职责 |
+|---|---|
+| `main.py` | 主循环 + 两个检测模块的编排 |
+| `config.py` | 配置加载、`${ENV}` 注入、端点默认值 `ENDPOINTS`、校验 |
+| `config.yaml` | 用户配置(只需填 key / 频次 / 邮箱 / 模型名) |
+| `probes.py` | 各 API 端点的探测实现与成败判定 |
+| `mailer.py` | IMAP 收件检测 + 原邮件转发 + 已发送去重 + SMTP 群发 |
+| `notifier.py` | 钉钉机器人 + `dual_forward`(转发下线通知)/ `dual_alert`(可用性告警) |
+
+## 部署
+
+```bash
+nohup python main.py -c config.yaml >> watchdog.log 2>&1 &
+```

+ 220 - 0
config.py

@@ -0,0 +1,220 @@
+"""配置加载: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

+ 104 - 0
config.yaml.example

@@ -0,0 +1,104 @@
+# 唯一的百炼 DashScope Key,所有模型探测共用
+api_key: "sk-xxxx"
+
+# 检测频次:每隔多少分钟跑一轮(360 = 每 6 小时一次)
+interval_minutes: 720
+
+# ================= 邮箱(收 + 发) =================
+mail:
+  imap_host: "imap.exmail.qq.com"
+  imap_port: 993
+  inbox_folder: "INBOX"
+  # 已发送文件夹;腾讯企业邮为 "Sent Messages",程序会自动回退尝试
+  # "Sent Messages" -> "Sent" -> "已发送"
+  sent_folder: "Sent Messages"
+
+  smtp_host: "smtp.exmail.qq.com"
+  smtp_port: 465          # 465=SSL;587 时把 smtp_ssl 改成 false
+  smtp_ssl: true
+
+  username: "cat@example.com"
+  password: "1234567"
+
+  # 发出下线告警后,额外用 IMAP APPEND 归档到已发送文件夹。
+  # 保持 true 可确保去重标记一定存在(部分邮箱 SMTP 发信不会自动归档到已发送)
+  append_to_sent: true
+
+  # 下线通知转发 + 可用性告警的群发收件人列表
+  notify_emails:
+    - "user@qq.com"
+
+# ================= 模块 A:模型下线通知检测 =================
+# 逻辑:近 N 天收件箱中,发件人域名属于 sender_domains 且正文/标题含 keyword 的邮件即为需告警;
+#       (状态存在已发送邮件里,本地不落盘)
+offline_notice:
+  keyword: "模型下线通知"
+  # 发件人**域名**白名单(不是完整邮箱地址)。
+  # 只校验域名,好处是:阿里云换了哪个具体邮箱号发都能收到,不会因为地址填错而漏消息;
+  # 同时又拦住了外部陌生人发含关键词的骚扰邮件。
+  # 匹配规则:域名相等或为其子域。aliyun.com 可命中 mail.aliyun.com,
+  #          但不会命中 notaliyun.com、aliyun.com.evil.cn。
+  sender_domains:
+    - "gxx12138.space"        # 联调测试用
+    - "aliyun.com"          # 正式上线打开
+    # - "aliyuncs.com"
+  lookback_days: 3
+
+# ================= 模块 B:模型可用性检测 =================
+# 按 API 端点分类,你只需要往对应分组的 models 列表里填模型名。
+# 端点地址/探测参数已内置默认值(见 config.py 的 ENDPOINTS),需要覆盖时在分组下写同名字段即可。
+models:
+  # 文本对话:https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions
+  chat:
+    enabled: true
+    models:
+      - "qwen3.5-plus"
+      - "qwen3.5-flash"
+
+  # 文本转语音:wss://dashscope.aliyuncs.com/api-ws/v1/inference/
+  # 只建连接 + 等 task-started,不发送待合成文本,所以基本不产生用量
+  tts:
+    enabled: true
+    voice: "longxiaochun_v2"   # 音色需与模型匹配,报错 InvalidParameter 时改这里
+    models:
+      - "cosyvoice-v3-flash"
+
+  # 语音转文本:https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription
+  asr:
+    enabled: true
+    models:
+      - "fun-asr"
+
+  # 文生图:https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis
+  text2image:
+    enabled: true
+    # probe_mode:
+    #   validate(默认,推荐)不传 prompt。阿里云是"先解析模型、再校验参数"的顺序,
+    #     所以模型在线会返回 400 "input.prompt should not be null",模型下线返回
+    #     400 "Model not exist.",两者可区分 —— 不出图、零费用。
+    #   submit  真实提交生成任务,端到端验证,但会产生生成费用。
+    probe_mode: "validate"
+    models:
+      - "wan2.2-t2i-flash"
+
+  # 文生视频:https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis
+  # 视频生成单次费用远高于其他端点,务必保持 probe_mode: validate
+  text2video:
+    enabled: true
+    probe_mode: "validate"
+    models:
+      - "wanx2.1-t2v-turbo"
+
+# ================= 钉钉机器人 =================
+# 凭证二选一:推荐只填 access_token;也可把整条 webhook 粘进 webhook 字段。
+# 支持 ${ENV} 注入,例如 access_token: "${DINGTALK_DEV_NOTIFY_TOKEN}"
+dingtalk:
+  enabled: true
+  # 运维工作提醒机器人 dev_notify
+  access_token: "ss-xxx"
+  secret: "as-xxx"
+  webhook: ""                     # 留空则用官方地址 oapi.dingtalk.com/robot/send
+  at_all: false                   # true = @所有人
+  timeout_seconds: 15
+  at_mobiles:                     # 需要 @ 的成员手机号
+    # - "13800000000"

+ 407 - 0
docs/index.html

@@ -0,0 +1,407 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>阿里云百炼模型下线监控系统 · 技术方案</title>
+<style>
+  :root {
+    --bg: #0b1020;
+    --panel: #131a30;
+    --panel2: #1a2340;
+    --line: #2a3558;
+    --text: #e6ecff;
+    --muted: #93a0c4;
+    --blue: #38bdf8;
+    --cyan: #2dd4bf;
+    --amber: #fbbf24;
+    --red: #f87171;
+    --violet: #a78bfa;
+  }
+  * { box-sizing: border-box; margin: 0; padding: 0; }
+  body {
+    font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
+    background: radial-gradient(1200px 600px at 20% -10%, #16224a 0%, var(--bg) 55%);
+    color: var(--text);
+    line-height: 1.7;
+    padding: 32px 20px 80px;
+  }
+  .wrap { max-width: 1080px; margin: 0 auto; }
+  header { text-align: center; padding: 26px 0 8px; }
+  header h1 {
+    font-size: 30px; letter-spacing: 1px;
+    background: linear-gradient(90deg, var(--blue), var(--cyan));
+    -webkit-background-clip: text; background-clip: text; color: transparent;
+  }
+  header .sub { color: var(--muted); margin-top: 10px; font-size: 15px; }
+  .tags { margin-top: 14px; display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; }
+  .tag {
+    border: 1px solid var(--line); background: var(--panel);
+    padding: 4px 14px; border-radius: 999px; font-size: 13px; color: var(--muted);
+  }
+  .tag b { color: var(--blue); font-weight: 600; }
+  section {
+    background: var(--panel);
+    border: 1px solid var(--line);
+    border-radius: 16px;
+    padding: 26px 30px;
+    margin-top: 26px;
+  }
+  section h2 {
+    font-size: 20px; margin-bottom: 6px;
+    display: flex; align-items: center; gap: 10px;
+  }
+  section h2 .no {
+    width: 28px; height: 28px; flex: 0 0 28px;
+    border-radius: 8px; display: inline-flex; align-items: center; justify-content: center;
+    font-size: 14px; font-weight: 700; color: #06121f;
+  }
+  .n1 { background: var(--blue); } .n2 { background: var(--cyan); }
+  .n3 { background: var(--amber); } .n4 { background: var(--red); }
+  .n5 { background: var(--violet); } .n6 { background: var(--blue); }
+  .n7 { background: var(--cyan); }
+  section .lead { color: var(--muted); font-size: 14px; margin-bottom: 18px; }
+  .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
+  .card {
+    background: var(--panel2); border: 1px solid var(--line);
+    border-radius: 12px; padding: 16px 18px;
+  }
+  .card h4 { font-size: 15px; margin-bottom: 6px; }
+  .card h4 i { font-style: normal; margin-right: 8px; }
+  .card p { font-size: 13.5px; color: var(--muted); }
+  .svgbox { margin-top: 14px; background: #0d1426; border: 1px solid var(--line); border-radius: 12px; padding: 8px; }
+  .svgbox svg { width: 100%; height: auto; display: block; }
+  .note {
+    margin-top: 12px; font-size: 13px; color: var(--muted);
+    border-left: 3px solid var(--amber); padding: 6px 12px; background: rgba(251,191,36,.06);
+  }
+  .note.blue { border-color: var(--blue); background: rgba(56,189,248,.06); }
+  .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
+  @media (max-width: 760px) { .grid2 { grid-template-columns: 1fr; } }
+  .steps { list-style: none; }
+  .steps li {
+    padding: 10px 0 10px 34px; position: relative; font-size: 14px;
+    border-bottom: 1px dashed var(--line);
+  }
+  .steps li:last-child { border-bottom: none; }
+  .steps li::before {
+    content: attr(data-i); position: absolute; left: 0; top: 11px;
+    width: 22px; height: 22px; border-radius: 50%;
+    background: var(--panel2); border: 1px solid var(--blue);
+    display: flex; align-items: center; justify-content: center; font-size: 12px; color: var(--blue);
+  }
+  .steps li b { color: var(--blue); }
+  footer { text-align: center; color: var(--muted); font-size: 12.5px; margin-top: 34px; }
+</style>
+</head>
+<body>
+<div class="wrap">
+
+<header>
+  <h1>阿里云百炼 · 模型下线监控系统</h1>
+  <div class="sub">面向模型批量下架 / API 失效风险的自动化监控与聚合告警方案</div>
+  <div class="tags">
+    <span class="tag"><b>Python</b> 技术栈</span>
+    <span class="tag"><b>DashScope</b> 官方 SDK</span>
+    <span class="tag">双通道监测</span>
+    <span class="tag">零本地状态 · 云端去重</span>
+    <span class="tag">钉钉 + 邮件双出口</span>
+  </div>
+</header>
+
+<!-- ============ 1. 系统概览 ============ -->
+<section>
+  <h2><span class="no n1">1</span>系统概览</h2>
+  <p class="lead">应对"阿里云百炼不定期下架过期模型、API 用着用着突然失效"的问题,构建一个部署在 Linux 服务器上的轻量监控服务。</p>
+  <div class="cards">
+    <div class="card"><h4><i>🎯</i>核心目标</h4><p>第一时间感知模型下线与请求异常,把"用户被动发现 API 失效"变为"系统主动告警"。</p></div>
+    <div class="card"><h4><i>🔁</i>双通道监测</h4><p>① 定时轮询云端邮箱,监听阿里云模型下线通知;② 按配置频次主动调用每个模型验证可用性。</p></div>
+    <div class="card"><h4><i>📮</i>零本地状态</h4><p>不依赖任何本地文件做缓存;已告警标识直接查询<strong>云端发件箱</strong>获取,重启无副作用、多机可部署。</p></div>
+    <div class="card"><h4><i>📣</i>聚合告警</h4><p>本轮所有异常合并为一条消息,同时推送<strong>钉钉群机器人</strong>与<strong>邮件群发</strong>,通知到公司内部群。</p></div>
+  </div>
+</section>
+
+<!-- ============ 2. 系统架构 ============ -->
+<section>
+  <h2><span class="no n2">2</span>系统架构</h2>
+  <p class="lead">三层结构:外部依赖(左侧) → 监控系统核心(中) → 通知出口(下)。调度器按配置频次触发整轮流程。</p>
+  <div class="svgbox">
+  <svg viewBox="0 0 1000 640" xmlns="http://www.w3.org/2000/svg" font-family="'Microsoft YaHei','PingFang SC',sans-serif">
+    <defs>
+      <marker id="arr" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
+        <path d="M0,0 L9,4.5 L0,9 Z" fill="#5b6b96"/>
+      </marker>
+      <marker id="arrc" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
+        <path d="M0,0 L9,4.5 L0,9 Z" fill="#2dd4bf"/>
+      </marker>
+      <filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
+        <feDropShadow dx="0" dy="4" stdDeviation="6" flood-color="#000" flood-opacity=".4"/>
+      </filter>
+    </defs>
+
+    <!-- 外部系统 -->
+    <g filter="url(#shadow)">
+      <rect x="70"  y="36" width="260" height="64" rx="12" fill="#16214a" stroke="#38bdf8" stroke-width="1.4"/>
+      <text x="200" y="66" fill="#38bdf8" text-anchor="middle" font-size="15" font-weight="700">阿里云百炼 · DashScope API</text>
+      <text x="200" y="86" fill="#93a0c4" text-anchor="middle" font-size="12">被监控对象(模型推理服务)</text>
+
+      <rect x="670" y="36" width="260" height="64" rx="12" fill="#16214a" stroke="#fbbf24" stroke-width="1.4"/>
+      <text x="800" y="66" fill="#fbbf24" text-anchor="middle" font-size="15" font-weight="700">企业邮箱(云端)</text>
+      <text x="800" y="86" fill="#93a0c4" text-anchor="middle" font-size="12">收件箱 INBOX + 发件箱 Sent</text>
+    </g>
+
+    <!-- 监控系统容器 -->
+    <rect x="60" y="150" width="880" height="330" rx="18" fill="#111a33" stroke="#2a3558" stroke-width="1.4" stroke-dasharray="6 5"/>
+    <text x="500" y="176" fill="#5b6b96" text-anchor="middle" font-size="14" font-weight="700">监 控 系 统  ·  零本地状态(无任何本地文件缓存)</text>
+
+    <!-- 模块行 -->
+    <g filter="url(#shadow)">
+      <rect x="110" y="205" width="210" height="78" rx="12" fill="#1a2340" stroke="#38bdf8"/>
+      <text x="215" y="234" fill="#e6ecff" text-anchor="middle" font-size="14" font-weight="700">定时调度器</text>
+      <text x="215" y="256" fill="#93a0c4" text-anchor="middle" font-size="12">按配置频次触发整轮</text>
+
+      <rect x="395" y="205" width="210" height="78" rx="12" fill="#1a2340" stroke="#fbbf24"/>
+      <text x="500" y="234" fill="#e6ecff" text-anchor="middle" font-size="14" font-weight="700">邮件检查器</text>
+      <text x="500" y="256" fill="#93a0c4" text-anchor="middle" font-size="12">云端收件监听 + 发件箱去重</text>
+
+      <rect x="680" y="205" width="210" height="78" rx="12" fill="#1a2340" stroke="#2dd4bf"/>
+      <text x="785" y="234" fill="#e6ecff" text-anchor="middle" font-size="14" font-weight="700">模型探测器</text>
+      <text x="785" y="256" fill="#93a0c4" text-anchor="middle" font-size="12">chat / image / audio 三种类型</text>
+    </g>
+
+    <!-- 聚合器 -->
+    <g filter="url(#shadow)">
+      <rect x="300" y="330" width="400" height="78" rx="12" fill="#1a2340" stroke="#a78bfa"/>
+      <text x="500" y="360" fill="#e6ecff" text-anchor="middle" font-size="14" font-weight="700">告警聚合器</text>
+      <text x="500" y="382" fill="#93a0c4" text-anchor="middle" font-size="12">下线通知 / 模型异常 → 聚合为一条告警</text>
+    </g>
+
+    <!-- 出口 -->
+    <g filter="url(#shadow)">
+      <rect x="150" y="556" width="290" height="64" rx="12" fill="#16214a" stroke="#f87171"/>
+      <text x="295" y="586" fill="#f87171" text-anchor="middle" font-size="14" font-weight="700">钉钉群机器人</text>
+      <text x="295" y="606" fill="#93a0c4" text-anchor="middle" font-size="12">HMAC 加签 · Markdown 推送</text>
+
+      <rect x="560" y="556" width="290" height="64" rx="12" fill="#16214a" stroke="#f87171"/>
+      <text x="705" y="586" fill="#f87171" text-anchor="middle" font-size="14" font-weight="700">SMTP 群发 → 通知人邮箱</text>
+      <text x="705" y="606" fill="#93a0c4" text-anchor="middle" font-size="12">标题带 [ALERT-MSG:ID],留存云端发件箱</text>
+    </g>
+
+    <!-- 箭头 -->
+    <line x1="330" y1="84"  x2="740" y2="200" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr)"/>
+    <line x1="820" y1="100" x2="620" y2="200" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr)"/>
+    <path d="M820,150 L820,120 L500,120 L500,200" fill="none" stroke="#2dd4bf" stroke-width="1.6" stroke-dasharray="5 4" marker-end="url(#arrc)"/>
+    <text x="500" y="112" fill="#2dd4bf" text-anchor="middle" font-size="11">查询云端发件箱 Sent 去重</text>
+
+    <line x1="215" y1="283" x2="215" y2="330" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr)"/>
+    <line x1="500" y1="283" x2="500" y2="330" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr)"/>
+    <line x1="785" y1="283" x2="700" y2="330" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr)"/>
+
+    <path d="M380,408 L295,552" fill="none" stroke="#f87171" stroke-width="1.8" marker-end="url(#arr)"/>
+    <path d="M620,408 L680,552" fill="none" stroke="#f87171" stroke-width="1.8" marker-end="url(#arr)"/>
+  </svg>
+  </div>
+  <div class="note blue">关键设计:监控系统<strong>不落任何本地文件</strong>。去重依据来自<strong>云端发件箱留存</strong>——服务重启、迁移、多副本部署均不会丢失告警状态。</div>
+</section>
+
+<!-- ============ 3. 单轮执行流程 ============ -->
+<section>
+  <h2><span class="no n3">3</span>单轮执行流程</h2>
+  <p class="lead">每次轮询:邮件检查与模型探测<strong>并行</strong>执行;本轮结束时统一汇总,异常事件聚合推送,全部正常则静默。</p>
+  <div class="svgbox">
+  <svg viewBox="0 0 1000 700" xmlns="http://www.w3.org/2000/svg" font-family="'Microsoft YaHei','PingFang SC',sans-serif">
+    <defs>
+      <marker id="arr2" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
+        <path d="M0,0 L9,4.5 L0,9 Z" fill="#5b6b96"/>
+      </marker>
+      <marker id="arrr" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
+        <path d="M0,0 L9,4.5 L0,9 Z" fill="#f87171"/>
+      </marker>
+      <marker id="arrg" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
+        <path d="M0,0 L9,4.5 L0,9 Z" fill="#2dd4bf"/>
+      </marker>
+    </defs>
+
+    <!-- 开始 -->
+    <rect x="380" y="26" width="240" height="52" rx="26" fill="#16214a" stroke="#38bdf8" stroke-width="1.6"/>
+    <text x="500" y="58" fill="#38bdf8" text-anchor="middle" font-size="14" font-weight="700">定时触发(按配置频次)</text>
+
+    <line x1="500" y1="78" x2="500" y2="116" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr2)"/>
+
+    <!-- 并行容器 -->
+    <rect x="120" y="120" width="760" height="130" rx="14" fill="#0d1426" stroke="#2a3558" stroke-dasharray="6 5"/>
+    <text x="500" y="146" fill="#5b6b96" text-anchor="middle" font-size="13" font-weight="700">并 行 执 行</text>
+
+    <rect x="170" y="162" width="330" height="72" rx="12" fill="#1a2340" stroke="#fbbf24"/>
+    <text x="335" y="188" fill="#e6ecff" text-anchor="middle" font-size="13" font-weight="700">检查云端收件箱(近 3 天)</text>
+    <text x="335" y="210" fill="#93a0c4" text-anchor="middle" font-size="12">关键词匹配 · 发件人过滤 · 发件箱去重</text>
+
+    <rect x="540" y="162" width="330" height="72" rx="12" fill="#1a2340" stroke="#2dd4bf"/>
+    <text x="705" y="188" fill="#e6ecff" text-anchor="middle" font-size="13" font-weight="700">探测全部配置模型</text>
+    <text x="705" y="210" fill="#93a0c4" text-anchor="middle" font-size="12">dashscope SDK · chat / image / audio</text>
+
+    <line x1="335" y1="250" x2="480" y2="310" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr2)"/>
+    <line x1="705" y1="250" x2="560" y2="310" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr2)"/>
+
+    <!-- 判定菱形 -->
+    <path d="M500,310 L650,378 L500,446 L350,378 Z" fill="#16214a" stroke="#fbbf24" stroke-width="1.6"/>
+    <text x="500" y="378" fill="#fbbf24" text-anchor="middle" font-size="14" font-weight="700">存在下线通知</text>
+    <text x="500" y="400" fill="#fbbf24" text-anchor="middle" font-size="14" font-weight="700">或模型异常?</text>
+
+    <!-- 否 分支 -->
+    <line x1="500" y1="446" x2="500" y2="500" stroke="#2dd4bf" stroke-width="1.6" marker-end="url(#arrg)"/>
+    <rect x="390" y="500" width="220" height="50" rx="10" fill="#1a2340" stroke="#2dd4bf"/>
+    <text x="500" y="530" fill="#2dd4bf" text-anchor="middle" font-size="13" font-weight="700">本轮结束 · 静默无操作</text>
+
+    <!-- 是 分支 -->
+    <line x1="650" y1="378" x2="760" y2="378" stroke="#f87171" stroke-width="1.6" marker-end="url(#arrr)"/>
+    <text x="698" y="366" fill="#f87171" text-anchor="middle" font-size="12">是</text>
+    <text x="320" y="366" fill="#2dd4bf" text-anchor="middle" font-size="12">否</text>
+
+    <rect x="762" y="336" width="220" height="84" rx="12" fill="#1a2340" stroke="#f87171"/>
+    <text x="872" y="364" fill="#e6ecff" text-anchor="middle" font-size="13" font-weight="700">聚合告警</text>
+    <text x="872" y="386" fill="#93a0c4" text-anchor="middle" font-size="12">邮件事件逐条 + 模型异常合并</text>
+
+    <line x1="872" y1="420" x2="872" y2="470" stroke="#f87171" stroke-width="1.6" marker-end="url(#arrr)"/>
+
+    <rect x="762" y="472" width="220" height="84" rx="12" fill="#1a2340" stroke="#f87171"/>
+    <text x="872" y="500" fill="#e6ecff" text-anchor="middle" font-size="13" font-weight="700">双出口推送</text>
+    <text x="872" y="522" fill="#93a0c4" text-anchor="middle" font-size="12">钉钉 + SMTP 群发,标题带 [ALERT-MSG]</text>
+
+    <line x1="872" y1="556" x2="872" y2="600" stroke="#f87171" stroke-width="1.6" marker-end="url(#arrr)"/>
+    <rect x="762" y="602" width="220" height="50" rx="10" fill="#1a2340" stroke="#2dd4bf"/>
+    <text x="872" y="632" fill="#2dd4bf" text-anchor="middle" font-size="13" font-weight="700">告警留存云端发件箱(供下轮去重)</text>
+  </svg>
+  </div>
+</section>
+
+<!-- ============ 4. 调度策略 ============ -->
+<section>
+  <h2><span class="no n4">4</span>调度策略</h2>
+  <p class="lead">配置"每天执行次数"即可,系统按 24 / N 小时均匀分布整点触发;也可用标准 cron 表达式精确指定时刻。</p>
+  <div class="grid2">
+    <div class="svgbox">
+    <svg viewBox="0 0 520 150" xmlns="http://www.w3.org/2000/svg" font-family="'Microsoft YaHei','PingFang SC',sans-serif">
+      <line x1="40" y1="90" x2="480" y2="90" stroke="#2a3558" stroke-width="2"/>
+      <circle cx="60"  cy="90" r="12" fill="#16214a" stroke="#f87171" stroke-width="2"/>
+      <circle cx="300" cy="90" r="12" fill="#16214a" stroke="#38bdf8" stroke-width="2"/>
+      <circle cx="460" cy="90" r="12" fill="#16214a" stroke="#2dd4bf" stroke-width="2"/>
+      <text x="60"  y="52" fill="#f87171" text-anchor="middle" font-size="15" font-weight="700">00:00</text>
+      <text x="60"  y="70" fill="#93a0c4" text-anchor="middle" font-size="11">触发点</text>
+      <text x="300" y="52" fill="#38bdf8" text-anchor="middle" font-size="15" font-weight="700">12:00</text>
+      <text x="300" y="70" fill="#93a0c4" text-anchor="middle" font-size="11">触发点</text>
+      <text x="460" y="52" fill="#2dd4bf" text-anchor="middle" font-size="15" font-weight="700">24:00</text>
+      <text x="460" y="70" fill="#93a0c4" text-anchor="middle" font-size="11">次日零点</text>
+      <text x="260" y="128" fill="#93a0c4" text-anchor="middle" font-size="12">示例:每天 2 次 → 00:00 与 12:00</text>
+    </svg>
+    </div>
+    <div>
+      <ul class="steps">
+        <li data-i="1"><b>数字频次</b>:配置 <code>daily_count = N</code>,即每天 N 次,按 24/N 小时均匀分布整点。</li>
+        <li data-i="2"><b>精确时刻</b>:可选用标准 cron 表达式(分 时 日 月 周)指定任意时刻,优先级高于数字频次。</li>
+        <li data-i="3"><b>单轮超时</b>:单轮执行设整体超时保护,避免卡死影响后续轮次。</li>
+        <li data-i="4"><b>时间窗口</b>:邮件仅检索<strong>近 3 天</strong>收到的通知,天然屏蔽历史陈旧通知,避免重复告警。</li>
+      </ul>
+    </div>
+  </div>
+</section>
+
+<!-- ============ 5. 模型探测 ============ -->
+<section>
+  <h2><span class="no n5">5</span>模型探测机制</h2>
+  <p class="lead">基于阿里云官方 dashscope SDK 发起推理请求,按 DashScope 协议判定健康状态。</p>
+  <div class="cards">
+    <div class="card"><h4><i>💬</i>chat · 文本对话</h4><p>文本对话类模型(如 qwen-turbo / qwen-plus / qwen-max),发送一句固定问候语验证。</p></div>
+    <div class="card"><h4><i>🖼️</i>image · 图像输入</h4><p>多模态图像输入模型(如 qwen-vl 系列),附带一张示例图与固定提问验证。</p></div>
+    <div class="card"><h4><i>🎙️</i>audio · 音频输入</h4><p>音频输入模型(如 qwen-audio 系列),附带一段示例音频与固定提问验证。</p></div>
+  </div>
+  <div class="grid2" style="margin-top:14px">
+    <div class="card"><h4><i>✅</i>健康判定规则</h4><p><b>正常</b>:HTTP 200 且响应包含配置的必填字段。<br>
+    <b>警告</b>:HTTP 200 但缺少必填字段(响应格式异常)。<br>
+    <b>紧急</b>:HTTP 非 200(模型下线、鉴权失败、参数变更等)。</p></div>
+    <div class="card"><h4><i>📋</i>可校验内容</h4><p>每个模型可配置期望响应中<strong>必须包含的字段</strong>(点路径,如输出正文、请求 ID),缺字段即视为异常,防止"返回 200 但内容不对"的假成功。</p></div>
+  </div>
+  <div class="note">判定口径:默认响应成功返回 <strong>HTTP 200</strong> 状态码;一旦非 200 或字段缺失,即触发本轮告警。</div>
+</section>
+
+<!-- ============ 6. 邮件告警与去重 ============ -->
+<section>
+  <h2><span class="no n6">6</span>邮件告警与云端去重</h2>
+  <p class="lead">模型下线通知"仅告警一次",通过<strong>云端发件箱留存 + 标题内嵌 Message-ID</strong> 实现无状态去重,不依赖任何本地文件。</p>
+  <div class="svgbox">
+  <svg viewBox="0 0 1000 560" xmlns="http://www.w3.org/2000/svg" font-family="'Microsoft YaHei','PingFang SC',sans-serif">
+    <defs>
+      <marker id="arr3" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
+        <path d="M0,0 L9,4.5 L0,9 Z" fill="#5b6b96"/>
+      </marker>
+      <marker id="arrc3" markerWidth="9" markerHeight="9" refX="7" refY="4.5" orient="auto">
+        <path d="M0,0 L9,4.5 L0,9 Z" fill="#2dd4bf"/>
+      </marker>
+    </defs>
+
+    <!-- 生命线 -->
+    <line x1="150" y1="60" x2="150" y2="520" stroke="#2a3558" stroke-width="1.4" stroke-dasharray="4 4"/>
+    <line x1="430" y1="60" x2="430" y2="520" stroke="#2a3558" stroke-width="1.4" stroke-dasharray="4 4"/>
+    <line x1="700" y1="60" x2="700" y2="520" stroke="#2a3558" stroke-width="1.4" stroke-dasharray="4 4"/>
+    <line x1="920" y1="60" x2="920" y2="520" stroke="#2a3558" stroke-width="1.4" stroke-dasharray="4 4"/>
+
+    <rect x="70"  y="22" width="160" height="34" rx="8" fill="#16214a" stroke="#fbbf24"/>
+    <text x="150" y="44" fill="#fbbf24" text-anchor="middle" font-size="13" font-weight="700">阿里云通知</text>
+    <rect x="350" y="22" width="160" height="34" rx="8" fill="#16214a" stroke="#38bdf8"/>
+    <text x="430" y="44" fill="#38bdf8" text-anchor="middle" font-size="13" font-weight="700">监控系统</text>
+    <rect x="610" y="22" width="180" height="34" rx="8" fill="#16214a" stroke="#a78bfa"/>
+    <text x="700" y="44" fill="#a78bfa" text-anchor="middle" font-size="13" font-weight="700">SMTP · 云端发件箱</text>
+    <rect x="840" y="22" width="160" height="34" rx="8" fill="#16214a" stroke="#f87171"/>
+    <text x="920" y="44" fill="#f87171" text-anchor="middle" font-size="13" font-weight="700">通知人</text>
+
+    <!-- 1 收件 -->
+    <line x1="160" y1="90" x2="420" y2="90" stroke="#5b6b96" stroke-width="1.6" marker-end="url(#arr3)"/>
+    <text x="290" y="82" fill="#e6ecff" text-anchor="middle" font-size="12">发送模型下线通知邮件</text>
+    <text x="290" y="106" fill="#93a0c4" text-anchor="middle" font-size="11">落入收件箱 INBOX</text>
+
+    <!-- 2 判断 -->
+    <rect x="300" y="128" width="260" height="54" rx="10" fill="#1a2340" stroke="#38bdf8"/>
+    <text x="430" y="150" fill="#e6ecff" text-anchor="middle" font-size="12" font-weight="700">匹配关键词 / 发件人过滤</text>
+    <text x="430" y="170" fill="#93a0c4" text-anchor="middle" font-size="11">查询云端发件箱 → 无此 ID → 判定为新事件</text>
+
+    <!-- 3 群发 -->
+    <line x1="440" y1="200" x2="688" y2="200" stroke="#f87171" stroke-width="1.6" marker-end="url(#arr3)"/>
+    <text x="560" y="192" fill="#f87171" text-anchor="middle" font-size="12" font-weight="700">群发告警,标题嵌入 [ALERT-MSG:原始ID]</text>
+    <line x1="710" y1="228" x2="910" y2="228" stroke="#f87171" stroke-width="1.6" marker-end="url(#arr3)"/>
+    <text x="810" y="220" fill="#e6ecff" text-anchor="middle" font-size="12">告警邮件送达通知人</text>
+
+    <!-- 4 留存 -->
+    <rect x="610" y="256" width="180" height="52" rx="10" fill="#1a2340" stroke="#a78bfa"/>
+    <text x="700" y="276" fill="#e6ecff" text-anchor="middle" font-size="12" font-weight="700">告警邮件留存发件箱 Sent</text>
+    <text x="700" y="296" fill="#93a0c4" text-anchor="middle" font-size="11">标题含 Message-ID,供后续查询</text>
+
+    <!-- 5 下轮去重 -->
+    <text x="430" y="352" fill="#2dd4bf" text-anchor="middle" font-size="12" font-weight="700">下一轮触发</text>
+    <line x1="440" y1="368" x2="690" y2="368" stroke="#2dd4bf" stroke-width="1.6" stroke-dasharray="6 4" marker-end="url(#arrc3)"/>
+    <text x="560" y="360" fill="#93a0c4" text-anchor="middle" font-size="11">查询发件箱中已告警的 Message-ID</text>
+    <line x1="690" y1="396" x2="440" y2="396" stroke="#2dd4bf" stroke-width="1.6" stroke-dasharray="6 4" marker-end="url(#arrc3)"/>
+    <text x="560" y="412" fill="#2dd4bf" text-anchor="middle" font-size="11" font-weight="700">命中已存在 → 跳过,不重复告警</text>
+
+    <text x="430" y="470" fill="#93a0c4" text-anchor="middle" font-size="12">收件箱中该邮件也不再触发(近 3 天窗口内已被标记)</text>
+  </svg>
+  </div>
+  <div class="note"><b>去重原理</b>:告警邮件标题携带原始通知的 Message-ID 并留存于云端发件箱;下一轮直接查询发件箱即可得知"这条通知是否已告警过",<b>无需任何本地缓存</b>。</div>
+</section>
+
+<!-- ============ 7. 告警聚合与通知 ============ -->
+<section>
+  <h2><span class="no n7">7</span>告警聚合与双通道通知</h2>
+  <p class="lead">模型告警是即时性的:只要本轮探测到异常,就在本轮结束时聚合推送,不跨轮积压。</p>
+  <div class="grid2">
+    <div class="card"><h4><i>📦</i>聚合规则</h4><p>例:10 个模型中 3 个异常 → 本轮结束后把<strong>问题模型列表及错误详情合并为一条告警</strong>,而非逐条轰炸。邮件下线事件按事件逐条独立推送(因涉及各自去重)。</p></div>
+    <div class="card"><h4><i>🤖</i>钉钉群机器人</h4><p>向公司内部群推送 Markdown 告警,支持 HMAC 加签与指定成员 @提醒,@全体可选。</p></div>
+    <div class="card"><h4><i>📧</i>邮件群发</h4><p>通过 SMTP 向配置的通知人邮箱列表群发;标题内嵌 Message-ID 供云端发件箱去重闭环使用。</p></div>
+    <div class="card"><h4><i>🛡️</i>系统自身异常兜底</h4><p>邮箱连接失败等系统级错误<strong>仅走钉钉</strong>,避免"告警通道自身故障导致死循环刷邮件"。</p></div>
+  </div>
+  <div class="note blue">双通道同时推送:钉钉负责即时提醒与 @成员,邮件负责留存归档与二次通知,互为备份。</div>
+</section>
+
+<footer>阿里云百炼模型下线监控系统 · 技术方案(Python / dashscope SDK)</footer>
+</div>
+</body>
+</html>

+ 434 - 0
mailer.py

@@ -0,0 +1,434 @@
+"""邮件模块:IMAP 检测模型下线通知 + 转发给群发列表 + 转发记录去重。
+
+去重机制(本地不落盘,状态在已发送邮件的信头里):
+- 转发时在转发件上写入指向原邮件的 References 与 X-Forwarded-Msgid 信头
+- 每轮先扫"已发送"近 N 天这两个信头,收集所有"已被转发过的原邮件 Message-ID"
+- 命中即说明这封下线通知已转发过,跳过
+
+这是邮件协议原生的"转发关系"表达,标题可以保持干净,也不怕标题被改动。
+"""
+import imaplib
+import logging
+import re
+import smtplib
+import ssl
+import time
+from datetime import datetime, timedelta
+from email import message_from_bytes
+from email.header import Header, decode_header, make_header
+from email.mime.message import MIMEMessage
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.utils import formataddr, formatdate, parseaddr
+
+log = logging.getLogger("mailer")
+
+# 标记转发关系的自定义信头(与标准 References 双保险,防止服务商丢弃其一)
+FORWARD_HEADER = "X-Forwarded-Msgid"
+
+_ANGLE_MSGID_RE = re.compile(r"<([^<>\s]+)>")
+_FWD_HEADER_RE = re.compile(
+    r"^%s:\s*(.+)$" % re.escape(FORWARD_HEADER), re.IGNORECASE | re.MULTILINE
+)
+
+
+def normalize_message_id(message_id):
+    """把 <abc@host> 规整成 abc@host。"""
+    return (message_id or "").strip().strip("<>").strip()
+
+
+def build_forward_subject(origin_subject):
+    """转发件标题:干净的转发标识 + 原邮件标题(不再往标题里塞 Message-ID)。"""
+    origin = (origin_subject or "").strip()
+    if len(origin) > 120:
+        origin = origin[:120] + "…"
+    return f"【模型下线通知转发】{origin}"
+
+
+def normalize_domain(value):
+    """把用户可能写成 "@aliyun.com" / "noreply@aliyun.com" / "ALIYUN.COM"
+    的配置统一成小写裸域名 "aliyun.com"。
+    """
+    d = (value or "").strip().lower().rstrip(".")
+    if "@" in d:                 # 误填了完整邮箱地址,取 @ 后面的部分
+        d = d.rsplit("@", 1)[1]
+    return d.lstrip("@").strip()
+
+
+def match_sender_domain(from_addr, domains):
+    """发件人域名是否属于 domains 之一(本域或其子域)。
+
+    只接受"完全相等"或"以 .域名 结尾"两种情况,**不能用裸 endswith**:
+      aliyun.com        -> 命中 aliyun.com、mail.aliyun.com
+      notaliyun.com     -> 不命中(裸 endswith 会误放行,等于开后门)
+      aliyun.com.evil.cn-> 不命中
+    """
+    addr = (from_addr or "").strip().lower()
+    if "@" not in addr:
+        return False
+    host = addr.rsplit("@", 1)[1].strip().rstrip(".")
+    if not host:
+        return False
+    for d in domains:
+        d = normalize_domain(d)
+        if d and (host == d or host.endswith("." + d)):
+            return True
+    return False
+
+
+def _imap_quote(value):
+    """IMAP 字符串字面量转义(反斜杠和双引号)。"""
+    return '"%s"' % str(value).replace("\\", "\\\\").replace('"', '\\"')
+
+
+def _imap_or_from(domains):
+    """把多个域名拼成 IMAP 的 FROM 或条件。
+
+    IMAP 的 OR 只接受两个 key,多个要嵌套:
+      1 个 -> FROM "a"
+      2 个 -> OR FROM "a" FROM "b"
+      3 个 -> OR FROM "a" OR FROM "b" FROM "c"
+    """
+    terms = ["FROM %s" % _imap_quote(normalize_domain(d)) for d in domains if normalize_domain(d)]
+    if not terms:
+        return ""
+    expr = terms[-1]
+    for term in reversed(terms[:-1]):
+        expr = "OR %s %s" % (term, expr)
+    return expr
+
+
+def _imap_and(*parts):
+    """IMAP 搜索条件默认就是 AND,空条件跳过。"""
+    return " ".join(p for p in parts if p)
+
+
+# ========== IMAP 检测 ==========
+
+class MailChecker:
+    def __init__(self, mail_cfg, notice_cfg):
+        self.mail = mail_cfg
+        self.notice = notice_cfg
+
+    def find_offline_notices(self):
+        """返回需要转发的下线通知列表(已排除"已发送"中转发过的)。"""
+        days = max(1, int(self.notice.get("lookback_days", 3)))
+        since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y")
+
+        conn = self._connect()
+        try:
+            forwarded = self._forwarded_message_ids(conn, since)
+            log.info("[邮件] 已发送中近 %d 天的转发记录: %d 封原邮件", days, len(forwarded))
+
+            notices = self._scan_inbox(conn, since)
+            log.info("[邮件] 收件箱近 %d 天命中下线通知: %d 封", days, len(notices))
+
+            fresh = [n for n in notices if not self._already_forwarded(n["message_id"], forwarded)]
+            skipped = len(notices) - len(fresh)
+            if skipped:
+                log.info("[邮件] 其中 %d 封已转发过,跳过", skipped)
+            return fresh
+        finally:
+            try:
+                conn.logout()
+            except Exception:  # noqa: BLE001
+                pass
+
+    # ----- 内部实现 -----
+
+    def _connect(self):
+        host, port = self.mail["imap_host"], int(self.mail["imap_port"])
+        if port == 993:
+            conn = imaplib.IMAP4_SSL(host, port)
+        else:
+            conn = imaplib.IMAP4(host, port)
+            conn.starttls()
+        conn.login(self.mail["username"], self.mail["password"])
+        return conn
+
+    def _scan_inbox(self, conn, since):
+        """扫收件箱:发件人域名属于 sender_domains 且标题/正文含 keyword。"""
+        if conn.select(self.mail["inbox_folder"], readonly=True)[0] != "OK":
+            raise RuntimeError(f"打开收件箱 {self.mail['inbox_folder']} 失败")
+
+        domains = self.notice.get("sender_domains") or []
+        criteria = f"SINCE {since}"
+        if domains:
+            # IMAP 的 FROM 是子串匹配,先用它把范围缩小,减少 fetch 量;
+            # 精确的域名归属校验在下面逐封做。
+            criteria = _imap_and(criteria, _imap_or_from(domains))
+        else:
+            log.warning("[邮件] offline_notice.sender_domains 未配置,本轮不做发件人过滤")
+
+        typ, data = conn.search(None, criteria)
+        if typ != "OK" or not data or not data[0]:
+            return []
+
+        keyword = self.notice.get("keyword") or "模型下线通知"
+        notices = []
+        for num in data[0].split():
+            typ, msg_data = conn.fetch(num, "(RFC822)")
+            if typ != "OK" or not msg_data or not msg_data[0]:
+                continue
+            raw = msg_data[0][1]
+            msg = message_from_bytes(raw)
+
+            from_addr = parseaddr(msg.get("From", ""))[1]
+            # IMAP 的子串匹配不可信(evil@notaliyun.com 也会命中),必须精确校验域名归属
+            if domains and not match_sender_domain(from_addr, domains):
+                continue
+            # 告警邮件本身也会进自己的收件箱,必须排除,否则会自我触发
+            if from_addr.lower() == self.mail["username"].lower():
+                continue
+
+            subject = _decode_mime(msg.get("Subject"))
+            body = _body_text(msg)
+            if keyword not in f"{subject}\n{body}":
+                continue
+
+            message_id = normalize_message_id(msg.get("Message-ID"))
+            if not message_id:
+                # 极少数邮件没有 Message-ID,用发件人+日期+标题兜底出一个稳定标记
+                message_id = "nomid-%s-%s" % (from_addr, abs(hash(subject + msg.get("Date", ""))))
+            notices.append({
+                "message_id": message_id,
+                "subject": subject,
+                "from": from_addr,
+                "date": msg.get("Date", ""),
+                "body": body,
+                "raw": raw,          # 原始邮件字节,用于转发时附上完整原文
+            })
+        return notices
+
+    def _forwarded_message_ids(self, conn, since):
+        """扫"已发送"近 N 天的转发关系信头,返回已转发过的原邮件 Message-ID 集合。"""
+        folder = self._select_sent(conn)
+        if not folder:
+            return set()
+        typ, data = conn.search(None, f"SINCE {since}")
+        if typ != "OK" or not data or not data[0]:
+            return set()
+
+        nums = data[0].split()
+        # 一次 fetch 取回转发关系信头,避免逐封往返
+        typ, msg_data = conn.fetch(
+            b",".join(nums),
+            "(BODY.PEEK[HEADER.FIELDS (REFERENCES %s)])" % FORWARD_HEADER.upper(),
+        )
+        if typ != "OK" or not msg_data:
+            return set()
+
+        ids = set()
+        for item in msg_data:
+            if not isinstance(item, tuple) or len(item) < 2 or not item[1]:
+                continue
+            ids |= _extract_msgids(item[1].decode("utf-8", errors="ignore"))
+        return ids
+
+    def _select_sent(self, conn):
+        """依次尝试候选"已发送"文件夹名,返回成功选中的名字。"""
+        for folder in _sent_candidates(self.mail.get("sent_folder")):
+            try:
+                if conn.select(_quote_folder(folder), readonly=True)[0] == "OK":
+                    return folder
+            except Exception:  # noqa: BLE001 - 中文文件夹名编码问题等,换下一个候选
+                pass
+            log.info("[邮件] 已发送文件夹 %s 不可用,尝试下一个", folder)
+        log.warning("[邮件] 未找到可用的已发送文件夹,本轮无法去重(可能重复转发)")
+        return None
+
+    @staticmethod
+    def _already_forwarded(message_id, forwarded_ids):
+        """该原邮件 Message-ID 出现在已发送的转发关系信头里 -> 已转发过。"""
+        mid = normalize_message_id(message_id)
+        return bool(mid) and mid in forwarded_ids
+
+
+def _sent_candidates(configured):
+    names = [n for n in (configured, "Sent Messages", "Sent", "已发送", "INBOX.Sent") if n]
+    seen, out = set(), []
+    for n in names:
+        if n not in seen:
+            seen.add(n)
+            out.append(n)
+    return out
+
+
+def _quote_folder(name):
+    name = (name or "").strip()
+    if name.startswith('"') and name.endswith('"'):
+        return name
+    if any(ch.isspace() or ch in '(){%*"\\' for ch in name):
+        return '"%s"' % name
+    return name
+
+
+def _extract_msgids(raw_headers):
+    """从 References / X-Forwarded-Msgid 信头原文里抽出所有 Message-ID(已规整)。"""
+    ids = {m.group(1).strip() for m in _ANGLE_MSGID_RE.finditer(raw_headers)}
+    # 自定义信头可能是不带尖括号的裸值
+    for m in _FWD_HEADER_RE.finditer(raw_headers):
+        v = normalize_message_id(m.group(1))
+        if v:
+            ids.add(v)
+    return {i for i in ids if i}
+
+
+def _decode_mime(value):
+    """解码 MIME 编码标题(=?UTF-8?B?...?=)。"""
+    if not value:
+        return ""
+    try:
+        return str(make_header(decode_header(value)))
+    except Exception:  # noqa: BLE001
+        return value
+
+
+def _body_text(msg):
+    """提取纯文本正文;没有 text/plain 时退化为 text/html 原文。"""
+    plain, html = [], []
+    for part in (msg.walk() if msg.is_multipart() else [msg]):
+        ctype = part.get_content_type()
+        if ctype not in ("text/plain", "text/html"):
+            continue
+        payload = part.get_payload(decode=True)
+        if not payload:
+            continue
+        text = payload.decode(part.get_content_charset() or "utf-8", errors="ignore")
+        (plain if ctype == "text/plain" else html).append(text)
+    return "\n".join(plain or html)
+
+
+# ========== SMTP 群发 ==========
+
+def send_mail(mail_cfg, subject, body):
+    """向 notify_emails 群发一封纯文本邮件(用于模型可用性告警,无需去重)。"""
+    _send(mail_cfg, MIMEText(body, "plain", "utf-8"), subject, action="群发告警")
+
+
+def forward_notice(mail_cfg, notice):
+    """把检测到的下线通知原邮件转发给 notify_emails。
+
+    转发件结构:
+      text/plain      转发说明 + 原邮件正文(可直接阅读)
+      message/rfc822  原始邮件完整存档(.eml 附件,保留全部头信息)
+
+    去重关系写在信头里:References + X-Forwarded-Msgid 指向原邮件 Message-ID,
+    下轮扫"已发送"这两个信头即可判定该原邮件是否已转发过。
+    """
+    mid = normalize_message_id(notice["message_id"])
+    subject = build_forward_subject(notice["subject"])
+    intro = "\n".join([
+        "检测到模型下线通知,以下为原邮件转发。",
+        "",
+        f"原发件人  : {notice['from']}",
+        f"原发送时间: {notice['date']}",
+        f"原邮件标题: {notice['subject']}",
+        f"Message-ID: {mid}",
+        "",
+        "-" * 56,
+        "以下为原邮件正文:",
+        "",
+        notice.get("body") or "(原邮件无纯文本正文,请查看附件 original.eml)",
+        "",
+        "-" * 56,
+        "本邮件由 model-watchdog 自动转发。",
+    ])
+
+    msg = MIMEMultipart()
+    msg.attach(MIMEText(intro, "plain", "utf-8"))
+    if notice.get("raw"):
+        try:
+            att = MIMEMessage(message_from_bytes(notice["raw"]))
+            att.add_header("Content-Disposition", "attachment", filename="original.eml")
+            msg.attach(att)
+        except Exception as e:  # noqa: BLE001 - 附件失败不该阻断转发
+            log.warning("[邮件] 原邮件附件构造失败,仅转发正文: %s", e)
+    if notice.get("from"):
+        msg["Reply-To"] = notice["from"]
+    # 转发关系(去重依据)
+    msg["References"] = f"<{mid}>"
+    msg[FORWARD_HEADER] = mid
+
+    _send(mail_cfg, msg, subject, sent_marker=mid, action="转发下线通知")
+
+
+def _send(mail_cfg, msg, subject, sent_marker=None, action="发送"):
+    """填充信头 -> SMTP 投递 -> 必要时确认已发送归档。"""
+    recipients = mail_cfg["notify_emails"]
+    if not recipients:
+        raise ValueError("notify_emails 为空,无法群发")
+
+    msg["Subject"] = Header(subject, "utf-8")
+    msg["From"] = formataddr(("model-watchdog", mail_cfg["username"]))
+    msg["To"] = ", ".join(recipients)
+    msg["Date"] = formatdate(localtime=True)
+
+    if mail_cfg.get("smtp_ssl"):
+        server = smtplib.SMTP_SSL(
+            mail_cfg["smtp_host"], int(mail_cfg["smtp_port"]),
+            context=ssl.create_default_context(), timeout=30,
+        )
+    else:
+        server = smtplib.SMTP(mail_cfg["smtp_host"], int(mail_cfg["smtp_port"]), timeout=30)
+        server.starttls(context=ssl.create_default_context())
+    try:
+        server.login(mail_cfg["username"], mail_cfg["password"])
+        server.sendmail(mail_cfg["username"], recipients, msg.as_string())
+        log.info("[邮件] 已%s给 %d 人: %s", action, len(recipients), subject)
+    finally:
+        try:
+            server.quit()
+        except Exception:  # noqa: BLE001
+            pass
+
+    if sent_marker and mail_cfg.get("append_to_sent", True):
+        _ensure_in_sent(mail_cfg, msg, sent_marker)
+
+    if sent_marker and mail_cfg.get("append_to_sent", True):
+        _ensure_in_sent(mail_cfg, msg, sent_marker)
+
+
+def _ensure_in_sent(mail_cfg, msg, marker, attempts=4, delay=4):
+    """确认转发关系信头已出现在已发送里,否则 APPEND 补档。失败只告警不抛异常。
+
+    邮箱服务商自动归档存在延迟(腾讯企业邮实测 >2s),所以轮询重试若干次,
+    避免"其实已归档成功"却误报为可能重复转发。
+    """
+    checker = MailChecker(mail_cfg, {})
+    since = (datetime.now() - timedelta(days=1)).strftime("%d-%b-%Y")
+    try:
+        conn = checker._connect()
+    except Exception as e:  # noqa: BLE001
+        log.warning("[邮件] 无法确认已发送归档(IMAP 登录失败): %s", e)
+        return
+    try:
+        for _ in range(max(1, attempts)):
+            time.sleep(delay)
+            if checker._already_forwarded(marker, checker._forwarded_message_ids(conn, since)):
+                log.info("[邮件] 转发记录已确认在已发送中(服务商自动归档),去重生效")
+                return
+
+        folder = checker._select_sent(conn)
+        if not folder:
+            return
+        typ, _ = conn.append(
+            _quote_folder(folder), r"(\Seen)",
+            imaplib.Time2Internaldate(datetime.now().timestamp()),
+            msg.as_bytes(),
+        )
+        if typ == "OK":
+            log.info("[邮件] 已 APPEND 转发记录到 %s", folder)
+        else:
+            log.warning(
+                "[邮件] %ds 内未确认归档,且 %s 不接受 APPEND(返回 %s)。"
+                "若服务商稍后完成归档仍可去重,否则该条下线通知下轮会重复转发",
+                attempts * delay, folder, typ,
+            )
+    except Exception as e:  # noqa: BLE001
+        log.warning("[邮件] 确认已发送归档失败: %s", e)
+    finally:
+        try:
+            conn.logout()
+        except Exception:  # noqa: BLE001
+            pass

+ 170 - 0
main.py

@@ -0,0 +1,170 @@
+"""model-watchdog 入口:一个 loop,按 interval_minutes 反复执行两个检测模块。
+
+    python main.py                # 常驻循环
+    python main.py --once         # 只跑一轮
+    python main.py --once --dry-run   # 只检测并打印将要发送的告警,不真发
+
+每轮执行:
+    模块 A  check_offline_notice  收件箱下线通知检测 -> 转发原邮件 + 钉钉(已发送标题去重)
+    模块 B  check_model_health    模型可用性检测 -> 聚合告警邮件 + 钉钉
+"""
+import argparse
+import logging
+import sys
+import time
+from datetime import datetime, timedelta
+
+from config import ENDPOINTS, enabled_probe_targets, load_config
+from mailer import MailChecker
+from notifier import dual_alert, dual_forward
+from probes import probe_all
+
+log = logging.getLogger("watchdog")
+
+
+# ========== 模块 A:模型下线通知检测 ==========
+
+def check_offline_notice(cfg, dry_run=False):
+    """检测收件箱下线通知,未转发过的立即转发 + 钉钉推送。返回本轮转发条数。"""
+    checker = MailChecker(cfg["mail"], cfg["offline_notice"])
+    try:
+        notices = checker.find_offline_notices()
+    except Exception as e:  # noqa: BLE001 - IMAP 异常不能中断整轮
+        log.error("[模块A] 邮箱检测失败: %s", e)
+        return 0
+
+    if not notices:
+        log.info("[模块A] 无需要转发的模型下线通知")
+        return 0
+
+    for n in notices:
+        dual_forward(cfg, n, dry_run=dry_run)
+        log.info("[模块A] 已处理下线通知: %s", n["message_id"])
+
+    return len(notices)
+
+
+# ========== 模块 B:模型可用性检测 ==========
+
+def check_model_health(cfg, dry_run=False):
+    """全量探测配置的模型,把失败的聚合成一条双重告警。返回失败模型数。"""
+    targets = enabled_probe_targets(cfg)
+    if not targets:
+        log.info("[模块B] 未配置任何待探测模型")
+        return 0
+
+    results = probe_all(targets, cfg["api_key"])
+    failed = [r for r in results if not r["ok"]]
+    log.info("[模块B] 探测 %d 个模型,失败 %d 个", len(results), len(failed))
+    if not failed:
+        return 0
+
+    segments = []
+    for r in failed:
+        code = r["code"] or "未知错误"
+        message = r["message"] or "无错误描述"
+        raw = r["raw"] or "(无响应体)"
+        segments.append(
+            f"{r['model']}({r['label']})报错 {code} {message},完整响应 {raw}"
+        )
+
+    body = "\n".join([
+        "【模型可用性告警】",
+        "",
+        f"检测时间: {datetime.now():%Y-%m-%d %H:%M:%S}",
+        f"探测总数: {len(results)} 失败: {len(failed)}",
+        "",
+        ";\n".join(segments),
+        "",
+        "—— model-watchdog 自动告警",
+    ])
+    subject = f"【模型可用性告警】{len(failed)} 个模型不可用:" + "、".join(
+        r["model"] for r in failed
+    )
+    dual_alert(cfg, subject, body, dry_run=dry_run)
+    return len(failed)
+
+
+# ========== 单轮 + 主循环 ==========
+
+def run_once(cfg, dry_run=False, skip_mail=False, skip_probe=False):
+    started = datetime.now()
+    log.info("========== 开始第 %s 轮检测 ==========", started.strftime("%Y-%m-%d %H:%M:%S"))
+    notices = 0 if skip_mail else check_offline_notice(cfg, dry_run=dry_run)
+    failures = 0 if skip_probe else check_model_health(cfg, dry_run=dry_run)
+    log.info(
+        "========== 本轮结束,耗时 %.1fs,转发下线通知 %d 条,不可用模型 %d 个 ==========",
+        (datetime.now() - started).total_seconds(), notices, failures,
+    )
+
+
+def run_loop(cfg, **kwargs):
+    interval = cfg["interval_minutes"] * 60
+    while True:
+        try:
+            run_once(cfg, **kwargs)
+        except Exception:  # noqa: BLE001 - 单轮任何异常都不能让守护进程退出
+            log.exception("本轮检测出现未捕获异常,跳过本轮")
+        next_at = datetime.now() + timedelta(seconds=interval)
+        log.info("休眠 %d 分钟,下次检测 %s", cfg["interval_minutes"], next_at.strftime("%Y-%m-%d %H:%M:%S"))
+        time.sleep(interval)
+
+
+def main():
+    parser = argparse.ArgumentParser(description="阿里云百炼模型下线 & 可用性监控")
+    parser.add_argument("-c", "--config", default="config.yaml", help="配置文件路径")
+    parser.add_argument("--once", action="store_true", help="只执行一轮后退出")
+    parser.add_argument("--dry-run", action="store_true", help="只检测并打印告警内容,不真正发送")
+    parser.add_argument("--skip-mail", action="store_true", help="跳过模块 A(只测模型)")
+    parser.add_argument("--skip-probe", action="store_true", help="跳过模块 B(只测邮箱)")
+    parser.add_argument("--test-dingtalk", action="store_true",
+                        help="只给钉钉发一条测试消息后退出(用于单独联调机器人)")
+    args = parser.parse_args()
+
+    logging.basicConfig(
+        level=logging.INFO,
+        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S",
+    )
+
+    cfg = load_config(args.config)
+
+    if args.test_dingtalk:
+        from notifier import send_dingtalk, _to_markdown
+        title = "【model-watchdog】钉钉连通性测试"
+        body = "\n".join([
+            "这是一条测试消息,说明钉钉通道配置正确。",
+            "",
+            f"发送时间: {datetime.now():%Y-%m-%d %H:%M:%S}",
+            f"监控邮箱: {cfg['mail']['username']}",
+        ])
+        try:
+            send_dingtalk(cfg["dingtalk"], title, _to_markdown(title, body))
+            log.info("钉钉测试消息已发送,请到群里确认")
+            return 0
+        except Exception as e:  # noqa: BLE001
+            log.error("钉钉测试失败: %s", e)
+            return 1
+
+    summary = "、".join(
+        f"{ENDPOINTS[name]['label']}×{len(g['models'])}"
+        for name, g in cfg["models"].items()
+        if g["enabled"] and g["models"]
+    )
+    log.info("配置加载完成: 每 %d 分钟一轮 | 待测模型 %s | 邮箱 %s",
+             cfg["interval_minutes"], summary or "无", cfg["mail"]["username"])
+    log.info("下线通知匹配条件: 发件人域名=%s 关键词=%s 回溯=%d 天",
+             "、".join(cfg["offline_notice"]["sender_domains"]) or "(不限,有被外部邮件触发的风险)",
+             cfg["offline_notice"]["keyword"], cfg["offline_notice"]["lookback_days"])
+    if args.dry_run:
+        log.info("DRY-RUN 模式:不会真正发送任何告警")
+
+    opts = {"dry_run": args.dry_run, "skip_mail": args.skip_mail, "skip_probe": args.skip_probe}
+    if args.once:
+        run_once(cfg, **opts)
+    else:
+        run_loop(cfg, **opts)
+
+
+if __name__ == "__main__":
+    sys.exit(main() or 0)

+ 159 - 0
notifier.py

@@ -0,0 +1,159 @@
+"""告警出口:钉钉机器人 + 邮件群发,统一封装为"双重告警"。"""
+import base64
+import hashlib
+import hmac
+import logging
+import time
+
+import requests
+
+from mailer import forward_notice, send_mail
+
+log = logging.getLogger("notifier")
+
+# 钉钉群机器人固定接口地址;access_token 作为 query 参数传入
+DINGTALK_API = "https://oapi.dingtalk.com/robot/send"
+
+
+def _dingtalk_sign(secret):
+    """钉钉加签:毫秒时间戳 + "\\n" + secret,HMAC-SHA256 后 Base64。
+
+    返回 (timestamp, sign)。sign 交给 requests 的 params 去做 URL 编码,
+    不要自己 quote,否则容易出现二次编码导致验签失败。
+    """
+    ts = str(round(time.time() * 1000))
+    digest = hmac.new(
+        secret.encode("utf-8"),
+        f"{ts}\n{secret}".encode("utf-8"),
+        digestmod=hashlib.sha256,
+    ).digest()
+    return ts, base64.b64encode(digest).decode("utf-8")
+
+
+def send_dingtalk(dt_cfg, title, text):
+    """发送 markdown 消息到钉钉群机器人。未启用时直接跳过。
+
+    凭证支持两种写法,二选一:
+      access_token: "xxx"                      # 推荐,只填 token
+      webhook: "https://...?access_token=xxx"  # 兼容整条 webhook
+    配了 secret 就自动加签(对应机器人安全设置里的"加签"方式)。
+    """
+    if not dt_cfg.get("enabled"):
+        log.info("[钉钉] 未启用,跳过")
+        return
+
+    url = (dt_cfg.get("webhook") or DINGTALK_API).strip()
+    token = (dt_cfg.get("access_token") or "").strip()
+
+    params = {}
+    if "access_token=" not in url:
+        if not token:
+            log.warning("[钉钉] 已启用但未配置 access_token/webhook,跳过")
+            return
+        params["access_token"] = token
+
+    secret = (dt_cfg.get("secret") or "").strip()
+    if secret:
+        params["timestamp"], params["sign"] = _dingtalk_sign(secret)
+
+    at_mobiles = [str(m).strip() for m in (dt_cfg.get("at_mobiles") or []) if str(m).strip()]
+    at_all = bool(dt_cfg.get("at_all"))
+    # markdown 消息里必须出现 @手机号 字面量,@ 才会真的生效
+    if at_mobiles:
+        text = text + "\n\n" + " ".join(f"@{m}" for m in at_mobiles)
+
+    payload = {"msgtype": "markdown", "markdown": {"title": title, "text": text}}
+    if at_mobiles or at_all:
+        payload["at"] = {"atMobiles": at_mobiles, "isAtAll": at_all}
+
+    resp = requests.post(
+        url,
+        params=params,
+        json=payload,
+        headers={"Content-Type": "application/json"},
+        timeout=dt_cfg.get("timeout_seconds", 15),
+    )
+    resp.raise_for_status()
+    data = resp.json()
+    if data.get("errcode") != 0:
+        # errmsg 里通常直接写明原因(token 无效 / 验签失败 / 关键词不匹配 / 限流)
+        raise RuntimeError(
+            "钉钉返回错误 errcode=%s errmsg=%s" % (data.get("errcode"), data.get("errmsg"))
+        )
+    log.info("[钉钉] 告警已发送: %s", title)
+
+
+def dual_forward(cfg, notice, dry_run=False):
+    """下线通知双通道:邮件转发原文给通知列表 + 钉钉推送摘要。
+
+    任一通道失败不影响另一个。返回 (转发是否成功, 钉钉是否成功)。
+    """
+    mid = notice["message_id"]
+    if dry_run:
+        log.info(
+            "[DRY-RUN] 将转发下线通知\nMessage-ID: %s\n原发件人: %s\n原标题: %s",
+            mid, notice["from"], notice["subject"],
+        )
+        return True, True
+
+    fwd_ok = False
+    try:
+        forward_notice(cfg["mail"], notice)
+        fwd_ok = True
+    except Exception as e:  # noqa: BLE001
+        log.error("[告警] 下线通知转发失败: %s", e)
+
+    text = "\n".join([
+        "检测到模型下线通知,已转发给通知列表。",
+        "",
+        f"原发件人: {notice['from']}",
+        f"原发送时间: {notice['date']}",
+        f"原邮件标题: {notice['subject']}",
+        f"Message-ID: {mid}",
+        "",
+        "正文摘要:",
+        (notice.get("body") or "")[:500],
+    ])
+    ding_ok = False
+    try:
+        send_dingtalk(cfg["dingtalk"], "【模型下线通知】" + notice["subject"], _to_markdown(
+            "【模型下线通知】" + notice["subject"], text))
+        ding_ok = True
+    except Exception as e:  # noqa: BLE001
+        log.error("[告警] 钉钉发送失败: %s", e)
+
+    return fwd_ok, ding_ok
+
+
+def dual_alert(cfg, subject, body, dry_run=False):
+    """双重告警:邮件群发 + 钉钉推送。任一通道失败不影响另一个。
+
+    返回 (邮件是否成功, 钉钉是否成功)。
+    """
+    if dry_run:
+        log.info("[DRY-RUN] 将发送告警\n标题: %s\n正文:\n%s", subject, body)
+        return True, True
+
+    mail_ok = False
+    try:
+        send_mail(cfg["mail"], subject, body)
+        mail_ok = True
+    except Exception as e:  # noqa: BLE001
+        log.error("[告警] 邮件发送失败: %s", e)
+
+    ding_ok = False
+    try:
+        send_dingtalk(cfg["dingtalk"], subject, _to_markdown(subject, body))
+        ding_ok = True
+    except Exception as e:  # noqa: BLE001
+        log.error("[告警] 钉钉发送失败: %s", e)
+
+    return mail_ok, ding_ok
+
+
+def _to_markdown(subject, body):
+    """纯文本正文转钉钉 markdown:保留换行,避免被 md 合并成一行。"""
+    lines = ["### " + subject, ""]
+    for line in body.splitlines():
+        lines.append(line.rstrip() + "  " if line.strip() else "")
+    return "\n".join(lines)

+ 305 - 0
probes.py

@@ -0,0 +1,305 @@
+"""模型可用性探测:按 API 端点分类,每类一个探测函数。
+
+统一返回结果 dict:
+    {model, endpoint, label, ok, code, message, raw, latency_ms}
+
+判定原则:
+- HTTP 200 且响应含该端点应有的字段 -> 可用
+- 其他情况(非 200 / 缺字段 / 连接异常 / WS task-failed)-> 加入告警列表,
+  raw 保留完整响应体,供告警正文里的"完整响应"使用。
+"""
+import json
+import logging
+import re
+import time
+import uuid
+from concurrent.futures import ThreadPoolExecutor
+
+import requests
+
+log = logging.getLogger("probe")
+
+# websocket-client 在正常关闭连接时也会打 ERROR 日志,屏蔽掉避免污染告警日志
+logging.getLogger("websocket").setLevel(logging.CRITICAL)
+
+RAW_LIMIT = 1500          # 完整响应在告警里的最大保留长度
+MAX_WORKERS = 5           # 并发探测数
+
+# validate 模式下,命中该模式的 400 报错说明"模型在线,只是没给 prompt"
+_PROMPT_ERR_RE = re.compile(r"prompt", re.IGNORECASE)
+
+
+# ========== 对外入口 ==========
+
+def probe_all(targets, api_key):
+    """并发探测 targets = [(模型名, 分组配置), ...],返回结果列表(保持入参顺序)。"""
+    if not targets:
+        return []
+    with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(targets))) as pool:
+        results = list(pool.map(lambda t: probe_one(t[0], t[1], api_key), targets))
+    for r in results:
+        if r["ok"]:
+            log.info("[探测] %s (%s) 正常, %d ms", r["model"], r["label"], r["latency_ms"])
+        else:
+            log.warning("[探测] %s (%s) 异常: %s %s", r["model"], r["label"], r["code"], r["message"])
+    return results
+
+
+def probe_one(model, group, api_key):
+    endpoint = group["endpoint"]
+    fn = _PROBES.get(endpoint)
+    result = {
+        "model": model,
+        "endpoint": endpoint,
+        "label": group.get("label", endpoint),
+        "ok": False,
+        "code": "",
+        "message": "",
+        "raw": "",
+        "latency_ms": 0,
+    }
+    if fn is None:
+        result["code"] = "ConfigError"
+        result["message"] = f"未实现的端点类型: {endpoint}"
+        return result
+
+    start = time.monotonic()
+    try:
+        ok, code, message, raw = fn(model, group, api_key)
+    except Exception as e:  # noqa: BLE001 - 网络/超时/协议异常统一转为告警
+        ok, code, message, raw = False, type(e).__name__, str(e), ""
+    result["latency_ms"] = int((time.monotonic() - start) * 1000)
+    result.update(ok=ok, code=str(code or ""), message=str(message or ""), raw=_clip(raw))
+    return result
+
+
+# ========== 各端点探测实现 ==========
+
+def _probe_chat(model, g, api_key):
+    """OpenAI 兼容模式对话:只让模型回一个 ok,输出上限 max_tokens。"""
+    body = {
+        "model": model,
+        "messages": [{"role": "user", "content": g["prompt"]}],
+        "max_tokens": g["max_tokens"],
+        "temperature": 0,
+        "stream": False,
+    }
+    body.update(g.get("extra_params") or {})
+    resp = requests.post(
+        g["url"], headers=_headers(api_key), json=body, timeout=g["timeout_seconds"]
+    )
+    return _judge_http(resp, ("choices",))
+
+
+def _probe_asr(model, g, api_key):
+    """录音文件识别:异步提交任务,拿到 task_id 即认为模型可用(不轮询结果)。"""
+    body = {
+        "model": model,
+        "input": {"file_urls": [g["sample_audio_url"]]},
+        "parameters": {"channel_id": [0]},
+    }
+    resp = requests.post(
+        g["url"],
+        headers=_headers(api_key, async_task=True),
+        json=body,
+        timeout=g["timeout_seconds"],
+    )
+    return _judge_http(resp, ("output", "task_id"))
+
+
+def _probe_text2image(model, g, api_key):
+    """文生图:异步提交任务。
+
+    probe_mode=validate(默认):故意不传 prompt。服务端先解析模型再校验参数,
+        因此"模型不存在"与"缺少 prompt"可区分开,且不会真的出图、不产生费用。
+    probe_mode=submit:真实提交生成任务(会计费)。
+    """
+    validate = g.get("probe_mode", "validate") == "validate"
+    body = {
+        "model": model,
+        "input": {} if validate else {"prompt": g["prompt"]},
+        "parameters": {"n": 1, "size": g["size"]},
+    }
+    resp = requests.post(
+        g["url"],
+        headers=_headers(api_key, async_task=True),
+        json=body,
+        timeout=g["timeout_seconds"],
+    )
+    return _judge_async(resp, validate)
+
+
+def _probe_text2video(model, g, api_key):
+    """文生视频:异步提交任务,模式含义同文生图。"""
+    validate = g.get("probe_mode", "validate") == "validate"
+    body = {
+        "model": model,
+        "input": {} if validate else {"prompt": g["prompt"]},
+        "parameters": {"size": g["size"], "duration": g["duration"]},
+    }
+    resp = requests.post(
+        g["url"],
+        headers=_headers(api_key, async_task=True),
+        json=body,
+        timeout=g["timeout_seconds"],
+    )
+    return _judge_async(resp, validate)
+
+
+def _probe_tts(model, g, api_key):
+    """语音合成(WebSocket):建连 -> run-task -> 等 task-started。
+
+    不发送待合成文本、不发 finish-task,收到 task-started 立即断开,
+    因此只验证模型可用性,几乎不产生合成用量。
+    """
+    try:
+        import websocket  # websocket-client
+    except ImportError:
+        return False, "DependencyMissing", "缺少依赖 websocket-client,无法探测 wss 端点", ""
+
+    timeout = g["timeout_seconds"]
+    task_id = uuid.uuid4().hex
+    run_task = {
+        "header": {"action": "run-task", "task_id": task_id, "streaming": "duplex"},
+        "payload": {
+            "task_group": "audio",
+            "task": "tts",
+            "function": "SpeechSynthesizer",
+            "model": model,
+            "parameters": {
+                "text_type": "PlainText",
+                "voice": g["voice"],
+                "format": g["audio_format"],
+                "sample_rate": g["sample_rate"],
+            },
+            "input": {},
+        },
+    }
+
+    ws = websocket.create_connection(
+        g["url"],
+        header=[f"Authorization: bearer {api_key}", "X-DashScope-DataInspection: enable"],
+        timeout=timeout,
+    )
+    try:
+        ws.send(json.dumps(run_task))
+        deadline = time.monotonic() + timeout
+        while time.monotonic() < deadline:
+            frame = ws.recv()
+            if isinstance(frame, (bytes, bytearray)):   # 音频帧,探测阶段不应出现
+                continue
+            try:
+                msg = json.loads(frame)
+            except ValueError:
+                continue
+            event = (msg.get("header") or {}).get("event", "")
+            if event == "task-started":
+                return True, "", "", frame
+            if event in ("task-failed", "task-finished"):
+                header = msg.get("header") or {}
+                code = header.get("error_code") or event
+                message = header.get("error_message") or f"服务端返回 {event}"
+                return False, code, message, frame
+        return False, "Timeout", f"{timeout}s 内未收到 task-started 事件", ""
+    finally:
+        try:
+            ws.close()
+        except Exception:  # noqa: BLE001
+            pass
+
+
+_PROBES = {
+    "chat": _probe_chat,
+    "tts": _probe_tts,
+    "asr": _probe_asr,
+    "text2image": _probe_text2image,
+    "text2video": _probe_text2video,
+}
+
+
+# ========== 工具函数 ==========
+
+def _headers(api_key, async_task=False):
+    h = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+    if async_task:
+        h["X-DashScope-Async"] = "enable"
+    return h
+
+
+def _judge_async(resp, validate):
+    """判定异步生成端点(文生图/文生视频)的提交响应。
+
+    validate 模式下,仅因缺少 prompt 被拒说明模型解析已通过 -> 判为可用;
+    "Model not exist" / 鉴权失败 / 5xx 等一律判为不可用。
+    """
+    if not validate:
+        return _judge_http(resp, ("output", "task_id"))
+
+    raw = resp.text or ""
+    try:
+        data = resp.json()
+    except ValueError:
+        data = None
+
+    if resp.status_code == 200 and _has_path(data, ("output", "task_id")):
+        return True, "", "", raw
+
+    code, message = _extract_error(data)
+    if resp.status_code == 400 and _PROMPT_ERR_RE.search(message or ""):
+        return True, "", "", raw
+    return False, code or resp.status_code, message or f"HTTP {resp.status_code}", raw
+
+
+def _judge_http(resp, required_path):
+    """判定 HTTP 响应。required_path 为响应中必须存在的字段路径。"""
+    raw = resp.text or ""
+    try:
+        data = resp.json()
+    except ValueError:
+        data = None
+
+    if resp.status_code != 200:
+        code, message = _extract_error(data)
+        return False, code or resp.status_code, message or f"HTTP {resp.status_code}", raw
+
+    # DashScope 原生端点即使 HTTP 200 也可能在 body 里带 code 表示失败
+    if isinstance(data, dict) and data.get("code"):
+        code, message = _extract_error(data)
+        return False, code, message, raw
+
+    if not _has_path(data, required_path):
+        return (
+            False,
+            "BadResponse",
+            "HTTP 200 但响应缺少字段 " + ".".join(required_path),
+            raw,
+        )
+    return True, "", "", raw
+
+
+def _extract_error(data):
+    """兼容两种错误结构:OpenAI 兼容模式 {"error": {...}} 与原生 {"code","message"}。"""
+    if not isinstance(data, dict):
+        return "", ""
+    err = data.get("error")
+    if isinstance(err, dict):
+        return str(err.get("code") or err.get("type") or ""), str(err.get("message") or "")
+    if isinstance(err, str) and err:
+        return "", err
+    return str(data.get("code") or ""), str(data.get("message") or "")
+
+
+def _has_path(data, path):
+    cur = data
+    for seg in path:
+        if not isinstance(cur, dict) or seg not in cur:
+            return False
+        cur = cur[seg]
+    return cur not in (None, "", [], {})
+
+
+def _clip(text):
+    text = (text or "").strip()
+    if len(text) <= RAW_LIMIT:
+        return text
+    return text[:RAW_LIMIT] + f"…(已截断,原长 {len(text)})"

+ 3 - 0
requirements.txt

@@ -0,0 +1,3 @@
+PyYAML>=6.0
+requests>=2.28.0
+websocket-client>=1.6.0