probes.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. """模型可用性探测:按 API 端点分类,每类一个探测函数。
  2. 统一返回结果 dict:
  3. {model, endpoint, label, ok, code, message, raw, latency_ms}
  4. 判定原则:
  5. - HTTP 200 且响应含该端点应有的字段 -> 可用
  6. - 其他情况(非 200 / 缺字段 / 连接异常 / WS task-failed)-> 加入告警列表,
  7. raw 保留完整响应体,供告警正文里的"完整响应"使用。
  8. """
  9. import json
  10. import logging
  11. import re
  12. import time
  13. import uuid
  14. from concurrent.futures import ThreadPoolExecutor
  15. import requests
  16. log = logging.getLogger("probe")
  17. # websocket-client 在正常关闭连接时也会打 ERROR 日志,屏蔽掉避免污染告警日志
  18. logging.getLogger("websocket").setLevel(logging.CRITICAL)
  19. RAW_LIMIT = 1500 # 完整响应在告警里的最大保留长度
  20. MAX_WORKERS = 5 # 并发探测数
  21. # validate 模式下,命中该模式的 400 报错说明"模型在线,只是没给 prompt"
  22. _PROMPT_ERR_RE = re.compile(r"prompt", re.IGNORECASE)
  23. # ========== 对外入口 ==========
  24. def probe_all(targets, api_key):
  25. """并发探测 targets = [(模型名, 分组配置), ...],返回结果列表(保持入参顺序)。"""
  26. if not targets:
  27. return []
  28. with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(targets))) as pool:
  29. results = list(pool.map(lambda t: probe_one(t[0], t[1], api_key), targets))
  30. for r in results:
  31. if r["ok"]:
  32. log.info("[探测] %s (%s) 正常, %d ms", r["model"], r["label"], r["latency_ms"])
  33. else:
  34. log.warning("[探测] %s (%s) 异常: %s %s", r["model"], r["label"], r["code"], r["message"])
  35. return results
  36. def probe_one(model, group, api_key):
  37. endpoint = group["endpoint"]
  38. fn = _PROBES.get(endpoint)
  39. result = {
  40. "model": model,
  41. "endpoint": endpoint,
  42. "label": group.get("label", endpoint),
  43. "ok": False,
  44. "code": "",
  45. "message": "",
  46. "raw": "",
  47. "latency_ms": 0,
  48. }
  49. if fn is None:
  50. result["code"] = "ConfigError"
  51. result["message"] = f"未实现的端点类型: {endpoint}"
  52. return result
  53. start = time.monotonic()
  54. try:
  55. ok, code, message, raw = fn(model, group, api_key)
  56. except Exception as e: # noqa: BLE001 - 网络/超时/协议异常统一转为告警
  57. ok, code, message, raw = False, type(e).__name__, str(e), ""
  58. result["latency_ms"] = int((time.monotonic() - start) * 1000)
  59. result.update(ok=ok, code=str(code or ""), message=str(message or ""), raw=_clip(raw))
  60. return result
  61. # ========== 各端点探测实现 ==========
  62. def _probe_chat(model, g, api_key):
  63. """OpenAI 兼容模式对话:只让模型回一个 ok,输出上限 max_tokens。"""
  64. body = {
  65. "model": model,
  66. "messages": [{"role": "user", "content": g["prompt"]}],
  67. "max_tokens": g["max_tokens"],
  68. "temperature": 0,
  69. "stream": False,
  70. }
  71. body.update(g.get("extra_params") or {})
  72. resp = requests.post(
  73. g["url"], headers=_headers(api_key), json=body, timeout=g["timeout_seconds"]
  74. )
  75. return _judge_http(resp, ("choices",))
  76. def _probe_asr(model, g, api_key):
  77. """录音文件识别:异步提交任务,拿到 task_id 即认为模型可用(不轮询结果)。"""
  78. body = {
  79. "model": model,
  80. "input": {"file_urls": [g["sample_audio_url"]]},
  81. "parameters": {"channel_id": [0]},
  82. }
  83. resp = requests.post(
  84. g["url"],
  85. headers=_headers(api_key, async_task=True),
  86. json=body,
  87. timeout=g["timeout_seconds"],
  88. )
  89. return _judge_http(resp, ("output", "task_id"))
  90. def _probe_text2image(model, g, api_key):
  91. """文生图:异步提交任务。
  92. probe_mode=validate(默认):故意不传 prompt。服务端先解析模型再校验参数,
  93. 因此"模型不存在"与"缺少 prompt"可区分开,且不会真的出图、不产生费用。
  94. probe_mode=submit:真实提交生成任务(会计费)。
  95. """
  96. validate = g.get("probe_mode", "validate") == "validate"
  97. body = {
  98. "model": model,
  99. "input": {} if validate else {"prompt": g["prompt"]},
  100. "parameters": {"n": 1, "size": g["size"]},
  101. }
  102. resp = requests.post(
  103. g["url"],
  104. headers=_headers(api_key, async_task=True),
  105. json=body,
  106. timeout=g["timeout_seconds"],
  107. )
  108. return _judge_async(resp, validate)
  109. def _probe_text2video(model, g, api_key):
  110. """文生视频:异步提交任务,模式含义同文生图。"""
  111. validate = g.get("probe_mode", "validate") == "validate"
  112. body = {
  113. "model": model,
  114. "input": {} if validate else {"prompt": g["prompt"]},
  115. "parameters": {"size": g["size"], "duration": g["duration"]},
  116. }
  117. resp = requests.post(
  118. g["url"],
  119. headers=_headers(api_key, async_task=True),
  120. json=body,
  121. timeout=g["timeout_seconds"],
  122. )
  123. return _judge_async(resp, validate)
  124. def _probe_tts(model, g, api_key):
  125. """语音合成(WebSocket):建连 -> run-task -> 等 task-started。
  126. 不发送待合成文本、不发 finish-task,收到 task-started 立即断开,
  127. 因此只验证模型可用性,几乎不产生合成用量。
  128. """
  129. try:
  130. import websocket # websocket-client
  131. except ImportError:
  132. return False, "DependencyMissing", "缺少依赖 websocket-client,无法探测 wss 端点", ""
  133. timeout = g["timeout_seconds"]
  134. task_id = uuid.uuid4().hex
  135. run_task = {
  136. "header": {"action": "run-task", "task_id": task_id, "streaming": "duplex"},
  137. "payload": {
  138. "task_group": "audio",
  139. "task": "tts",
  140. "function": "SpeechSynthesizer",
  141. "model": model,
  142. "parameters": {
  143. "text_type": "PlainText",
  144. "voice": g["voice"],
  145. "format": g["audio_format"],
  146. "sample_rate": g["sample_rate"],
  147. },
  148. "input": {},
  149. },
  150. }
  151. ws = websocket.create_connection(
  152. g["url"],
  153. header=[f"Authorization: bearer {api_key}", "X-DashScope-DataInspection: enable"],
  154. timeout=timeout,
  155. )
  156. try:
  157. ws.send(json.dumps(run_task))
  158. deadline = time.monotonic() + timeout
  159. while time.monotonic() < deadline:
  160. frame = ws.recv()
  161. if isinstance(frame, (bytes, bytearray)): # 音频帧,探测阶段不应出现
  162. continue
  163. try:
  164. msg = json.loads(frame)
  165. except ValueError:
  166. continue
  167. event = (msg.get("header") or {}).get("event", "")
  168. if event == "task-started":
  169. return True, "", "", frame
  170. if event in ("task-failed", "task-finished"):
  171. header = msg.get("header") or {}
  172. code = header.get("error_code") or event
  173. message = header.get("error_message") or f"服务端返回 {event}"
  174. return False, code, message, frame
  175. return False, "Timeout", f"{timeout}s 内未收到 task-started 事件", ""
  176. finally:
  177. try:
  178. ws.close()
  179. except Exception: # noqa: BLE001
  180. pass
  181. _PROBES = {
  182. "chat": _probe_chat,
  183. "tts": _probe_tts,
  184. "asr": _probe_asr,
  185. "text2image": _probe_text2image,
  186. "text2video": _probe_text2video,
  187. }
  188. # ========== 工具函数 ==========
  189. def _headers(api_key, async_task=False):
  190. h = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
  191. if async_task:
  192. h["X-DashScope-Async"] = "enable"
  193. return h
  194. def _judge_async(resp, validate):
  195. """判定异步生成端点(文生图/文生视频)的提交响应。
  196. validate 模式下,仅因缺少 prompt 被拒说明模型解析已通过 -> 判为可用;
  197. "Model not exist" / 鉴权失败 / 5xx 等一律判为不可用。
  198. """
  199. if not validate:
  200. return _judge_http(resp, ("output", "task_id"))
  201. raw = resp.text or ""
  202. try:
  203. data = resp.json()
  204. except ValueError:
  205. data = None
  206. if resp.status_code == 200 and _has_path(data, ("output", "task_id")):
  207. return True, "", "", raw
  208. code, message = _extract_error(data)
  209. if resp.status_code == 400 and _PROMPT_ERR_RE.search(message or ""):
  210. return True, "", "", raw
  211. return False, code or resp.status_code, message or f"HTTP {resp.status_code}", raw
  212. def _judge_http(resp, required_path):
  213. """判定 HTTP 响应。required_path 为响应中必须存在的字段路径。"""
  214. raw = resp.text or ""
  215. try:
  216. data = resp.json()
  217. except ValueError:
  218. data = None
  219. if resp.status_code != 200:
  220. code, message = _extract_error(data)
  221. return False, code or resp.status_code, message or f"HTTP {resp.status_code}", raw
  222. # DashScope 原生端点即使 HTTP 200 也可能在 body 里带 code 表示失败
  223. if isinstance(data, dict) and data.get("code"):
  224. code, message = _extract_error(data)
  225. return False, code, message, raw
  226. if not _has_path(data, required_path):
  227. return (
  228. False,
  229. "BadResponse",
  230. "HTTP 200 但响应缺少字段 " + ".".join(required_path),
  231. raw,
  232. )
  233. return True, "", "", raw
  234. def _extract_error(data):
  235. """兼容两种错误结构:OpenAI 兼容模式 {"error": {...}} 与原生 {"code","message"}。"""
  236. if not isinstance(data, dict):
  237. return "", ""
  238. err = data.get("error")
  239. if isinstance(err, dict):
  240. return str(err.get("code") or err.get("type") or ""), str(err.get("message") or "")
  241. if isinstance(err, str) and err:
  242. return "", err
  243. return str(data.get("code") or ""), str(data.get("message") or "")
  244. def _has_path(data, path):
  245. cur = data
  246. for seg in path:
  247. if not isinstance(cur, dict) or seg not in cur:
  248. return False
  249. cur = cur[seg]
  250. return cur not in (None, "", [], {})
  251. def _clip(text):
  252. text = (text or "").strip()
  253. if len(text) <= RAW_LIMIT:
  254. return text
  255. return text[:RAW_LIMIT] + f"…(已截断,原长 {len(text)})"