codebee 0.1.102 → 0.1.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +21 -21
- package/README.md +4 -8
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/acp_client.py +457 -457
- package/app/core/aiflavor.py +197 -197
- package/app/core/analytics.py +38 -38
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -30
- package/app/core/backend_profiles.py +188 -188
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +102 -102
- package/app/core/bookmeta.py +1036 -1036
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/capability.py +144 -144
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/command_auth.py +302 -302
- package/app/core/compaction.py +201 -201
- package/app/core/contracts.py +255 -255
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +1 -1
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/errorlog.py +137 -137
- package/app/core/eval_matrix.py +91 -91
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flow_graph.py +33 -33
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +389 -389
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/knowledge_pipeline.py +86 -86
- package/app/core/manager.py +2385 -2385
- package/app/core/mocks.py +64 -64
- package/app/core/notify.py +201 -201
- package/app/core/novel_quality.py +181 -181
- package/app/core/openapi.py +217 -217
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +145 -145
- package/app/core/pipeline.py +2 -2
- package/app/core/portguard.py +111 -111
- package/app/core/presence.py +77 -77
- package/app/core/preview.py +293 -293
- package/app/core/project_memory.py +206 -206
- package/app/core/prompt_guard.py +78 -78
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +659 -659
- package/app/core/publish/chaprows.py +138 -138
- package/app/core/publish/flows-fanqie-calibrated.json +442 -442
- package/app/core/publish/manager.py +1185 -1185
- package/app/core/publish/qimao.py +193 -193
- package/app/core/publish/volumes.py +108 -108
- package/app/core/publish/ws.py +139 -139
- package/app/core/quality_gate.py +201 -201
- package/app/core/redact.py +38 -38
- package/app/core/repeat_guard.py +129 -129
- package/app/core/retrieval.py +137 -137
- package/app/core/sandbox_runtime.py +95 -95
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +215 -215
- package/app/core/settings_schema.py +282 -282
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +139 -139
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/story_tracking.py +275 -275
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tool_outputs.py +107 -107
- package/app/core/tracing.py +207 -207
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1038 -1038
- package/app/core/volumes.py +389 -389
- package/app/core/webhooks.py +89 -89
- package/app/core/wxdigest.py +865 -865
- package/app/core/zentao.py +2692 -2692
- package/app/mcp_server.py +244 -244
- package/app/output/_createbook_body.json +1 -0
- package/app/output/_probe_fq_click.py +50 -0
- package/app/output/_probe_fq_form.py +42 -0
- package/app/output/_probe_fq_modal.py +55 -0
- package/app/output/_probe_fq_points.py +71 -0
- package/app/output/_probe_fq_submit.py +34 -0
- package/app/output/_probe_fq_watch.py +62 -0
- package/app/output/workbench-clean-preview.jpg +0 -0
- package/app/output/workbench-reference-render.png +0 -0
- package/app/output/workbench-shell.png +0 -0
- package/app/pick_dialog.py +110 -110
- package/app/ui/app.js +2 -3
- package/app/ui/artdiff.js +74 -74
- package/app/ui/i18n.js +0 -1
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/bin/tutti.js +147 -147
- package/package.json +1 -1
- package/tools/wx_reader.py +261 -261
package/app/core/health.py
CHANGED
|
@@ -1,389 +1,389 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""供应商健康监测与告警:连不上模型/厂商时自动降级并告警,恢复自动解除。
|
|
3
|
-
|
|
4
|
-
设计稿:docs/migration/07-token-cost.md 的延伸(2026-09-15 连载验收实战需求:
|
|
5
|
-
cavoti 网关宕机 5 小时,系统只会反复失败重试,没有主动告知「通道已不可用」)。
|
|
6
|
-
|
|
7
|
-
状态机(按 provider):
|
|
8
|
-
ok 正常(有近期成功或不活跃)
|
|
9
|
-
failing 连续失败 ≥FAILING_AFTER(进入探测模式)
|
|
10
|
-
down failing 持续 ≥DOWN_AFTER_SECONDS 或连续失败 ≥DOWN_AFTER_FAILURES
|
|
11
|
-
→ 触发告警(未静默时)
|
|
12
|
-
recovered down/failing 后再次成功 → 自动解除告警,记一笔恢复
|
|
13
|
-
|
|
14
|
-
探针策略(省 token):正常时不主动探测(靠真实调用信号);failing/down 后按
|
|
15
|
-
退避(60s→120s→300s 封顶)发轻量探活(modelhub.chat,max_tokens=8)。
|
|
16
|
-
恢复 → 回正常节奏。
|
|
17
|
-
|
|
18
|
-
手动操作:
|
|
19
|
-
silence(provider) 静默本次告警(不再提醒,直到恢复或手动 reset)
|
|
20
|
-
reset(provider) 手动清除状态视为恢复(下次失败重新计数)
|
|
21
|
-
|
|
22
|
-
线程模型:上报(runner/pipeline/modelhub 多线程调用)走锁;探针单线程 daemon。
|
|
23
|
-
"""
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import json
|
|
27
|
-
import logging
|
|
28
|
-
import threading
|
|
29
|
-
import time
|
|
30
|
-
from pathlib import Path
|
|
31
|
-
|
|
32
|
-
log = logging.getLogger(__name__)
|
|
33
|
-
|
|
34
|
-
FAILING_AFTER = 2 # 连续失败 ≥2 → failing(进入探测)
|
|
35
|
-
DOWN_AFTER_FAILURES = 4 # 连续失败 ≥4 → down(告警)
|
|
36
|
-
DOWN_AFTER_SECONDS = 600 # 或 failing 持续 10 分钟 → down
|
|
37
|
-
PROBE_BACKOFF = (60, 120, 300) # 探测退避秒数(封顶 300)
|
|
38
|
-
SILENCE_FOREVER = -1 # 手动静默不自动过期(恢复时才清除)
|
|
39
|
-
|
|
40
|
-
_LOCK = threading.RLock()
|
|
41
|
-
_PROVIDERS = {} # name -> 状态 dict
|
|
42
|
-
_FILE = None # init() 注入(data/provider_health.json)
|
|
43
|
-
_PROBE_THREAD = None
|
|
44
|
-
_STARTED = False
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def init(data_dir=None, *, start_probe=True):
|
|
48
|
-
"""注入存储路径并从磁盘恢复状态(服务重启不丢告警上下文)。
|
|
49
|
-
|
|
50
|
-
``start_probe=False`` 供单元测试等受控场景使用,避免留下跨用例的
|
|
51
|
-
daemon 探针线程。
|
|
52
|
-
"""
|
|
53
|
-
global _FILE, _PROVIDERS, _STARTED, _PROBE_THREAD
|
|
54
|
-
with _LOCK:
|
|
55
|
-
base = Path(data_dir) if data_dir else Path(_default_dir())
|
|
56
|
-
base.mkdir(parents=True, exist_ok=True)
|
|
57
|
-
_FILE = base / "provider_health.json"
|
|
58
|
-
_PROVIDERS = {}
|
|
59
|
-
try:
|
|
60
|
-
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
61
|
-
for name, st in (data.get("providers") or {}).items():
|
|
62
|
-
if isinstance(st, dict) and st.get("name"):
|
|
63
|
-
_PROVIDERS[name] = st
|
|
64
|
-
except (OSError, json.JSONDecodeError):
|
|
65
|
-
pass
|
|
66
|
-
# 「绑定链·」静态告警胶囊已从产品移除(2026-09-18:普通用户误伤,
|
|
67
|
-
# 用户拍板)——老版本持久化的条目启动即清,别让 0.1.6 的状态阴魂不散。
|
|
68
|
-
stale = [k for k in _PROVIDERS if k.startswith("绑定链·")]
|
|
69
|
-
if stale:
|
|
70
|
-
for k in stale:
|
|
71
|
-
_PROVIDERS.pop(k, None)
|
|
72
|
-
_persist()
|
|
73
|
-
# 供应商已删除的幽灵条目启动即清;运行期新增的幽灵由探针在探活时
|
|
74
|
-
# 发现(gone 标记)随手清——探针循环里不做全量扫描,避免和测试
|
|
75
|
-
# 夹具/运行期建条目赛跑。
|
|
76
|
-
_prune_missing()
|
|
77
|
-
if start_probe and not _STARTED:
|
|
78
|
-
_STARTED = True
|
|
79
|
-
_PROBE_THREAD = threading.Thread(
|
|
80
|
-
target=_probe_loop, name="health-probe", daemon=True)
|
|
81
|
-
_PROBE_THREAD.start()
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _default_dir():
|
|
85
|
-
from . import paths
|
|
86
|
-
return str(paths.DATA_DIR)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _persist():
|
|
90
|
-
# This is telemetry state: a storage failure must not turn a successful or
|
|
91
|
-
# failed model call into a task failure. Callers already hold _LOCK, but
|
|
92
|
-
# keeping the path and snapshot read under it also protects future callers.
|
|
93
|
-
with _LOCK:
|
|
94
|
-
path = _FILE
|
|
95
|
-
if path is None:
|
|
96
|
-
return
|
|
97
|
-
try:
|
|
98
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
-
tmp = path.with_suffix(".tmp")
|
|
100
|
-
tmp.write_text(
|
|
101
|
-
json.dumps({"providers": _PROVIDERS}, ensure_ascii=False, indent=2),
|
|
102
|
-
encoding="utf-8")
|
|
103
|
-
tmp.replace(path)
|
|
104
|
-
except OSError as e:
|
|
105
|
-
log.warning("[health] 无法持久化供应商健康状态到 %s: %s", path, e)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _now():
|
|
109
|
-
return time.time()
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
def _fmt_ts(epoch):
|
|
113
|
-
if not epoch:
|
|
114
|
-
return ""
|
|
115
|
-
return time.strftime("%m-%d %H:%M", time.localtime(epoch))
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
# ---------------------------------------------------------------- 上报入口
|
|
119
|
-
|
|
120
|
-
def report_success(provider: str):
|
|
121
|
-
"""一次真实成功调用(CLI 或 API 直连)。"""
|
|
122
|
-
if not provider:
|
|
123
|
-
return
|
|
124
|
-
with _LOCK:
|
|
125
|
-
st = _PROVIDERS.get(provider)
|
|
126
|
-
if st is None:
|
|
127
|
-
return # 没失败过就不建档(零开销)
|
|
128
|
-
if st.get("status") in ("failing", "down"):
|
|
129
|
-
st["status"] = "recovered"
|
|
130
|
-
st["recovered_at"] = _now()
|
|
131
|
-
st["consecutive_failures"] = 0
|
|
132
|
-
st["last_ok_at"] = _now()
|
|
133
|
-
st["probe_next_at"] = 0
|
|
134
|
-
st["alerted"] = False
|
|
135
|
-
st["silenced"] = False # 静默只管一次故障期;恢复后重新武装
|
|
136
|
-
st["silence_until"] = 0
|
|
137
|
-
log.info("[health] %s 已恢复(告警自动解除)", provider)
|
|
138
|
-
else:
|
|
139
|
-
st["last_ok_at"] = _now()
|
|
140
|
-
st["consecutive_failures"] = 0
|
|
141
|
-
_persist()
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
# run_process 包裹 Popen 启动异常的固定前缀。与 error_codes._TOOL_LAUNCH 的
|
|
145
|
-
# 判定标记同源双写(error_codes 在 L0,health 在 L1,策略禁止 L1→L0 import;
|
|
146
|
-
# 供应商侧任何报错都不会带这个前缀,单靠前缀即可零误判)。
|
|
147
|
-
_LAUNCH_FAILURE_PREFIX = "启动失败"
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
151
|
-
provider_id: str = ""):
|
|
152
|
-
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
153
|
-
|
|
154
|
-
本地 CLI 启动失败(找不到可执行文件等)与供应商无关,记进来会把健康
|
|
155
|
-
供应商的评测结论作废、链被解析层过滤成死链——2026-09-30 claude 垫片被
|
|
156
|
-
清案:Bigmodel 全程健康却被连续启动失败拖进 down。只打日志提醒修本地
|
|
157
|
-
环境,不进健康台账。"""
|
|
158
|
-
if not provider:
|
|
159
|
-
return
|
|
160
|
-
if _LAUNCH_FAILURE_PREFIX in str(error or "")[:80]:
|
|
161
|
-
log.warning("[health] 本地 CLI 启动失败(与供应商 %s 无关,不记健康故障):%s",
|
|
162
|
-
provider, str(error or "")[:160])
|
|
163
|
-
return
|
|
164
|
-
|
|
165
|
-
# A failed live call invalidates capability evidence for this upstream.
|
|
166
|
-
# Keep this hook lazy and best-effort: health telemetry must never make a
|
|
167
|
-
# model call fail, and importing evaluation at module load would create a
|
|
168
|
-
# dependency cycle during startup. ``provider_id`` is the canonical
|
|
169
|
-
# identity used by the evaluation ledger; the display name is not safe to
|
|
170
|
-
# use as a substitute because aliases can point at the same host.
|
|
171
|
-
if provider_id:
|
|
172
|
-
try:
|
|
173
|
-
from . import evaluation
|
|
174
|
-
evaluation.invalidate_provider(provider_id, model=model)
|
|
175
|
-
except Exception:
|
|
176
|
-
log.debug("[health] 无法使评测结论失效", exc_info=True)
|
|
177
|
-
with _LOCK:
|
|
178
|
-
st = _PROVIDERS.setdefault(provider, {
|
|
179
|
-
"name": provider, "provider_id": provider_id or "",
|
|
180
|
-
"model": model or "", "status": "ok",
|
|
181
|
-
"consecutive_failures": 0, "first_fail_at": 0,
|
|
182
|
-
"last_fail_at": 0, "last_ok_at": 0, "last_error": "",
|
|
183
|
-
"alerted": False, "silenced": False, "silence_until": 0,
|
|
184
|
-
"probe_next_at": 0, "probe_backoff_idx": 0,
|
|
185
|
-
"recovered_at": 0,
|
|
186
|
-
})
|
|
187
|
-
st["name"] = provider
|
|
188
|
-
if provider_id and not st.get("provider_id"):
|
|
189
|
-
st["provider_id"] = provider_id
|
|
190
|
-
if model and not st.get("model"):
|
|
191
|
-
st["model"] = model
|
|
192
|
-
n = int(st.get("consecutive_failures") or 0) + 1
|
|
193
|
-
st["consecutive_failures"] = n
|
|
194
|
-
st["last_fail_at"] = _now()
|
|
195
|
-
st["last_error"] = str(error or "")[:300]
|
|
196
|
-
if st.get("status") in ("ok", "recovered", ""):
|
|
197
|
-
st["status"] = "failing"
|
|
198
|
-
st["first_fail_at"] = _now()
|
|
199
|
-
st["probe_next_at"] = _now() + PROBE_BACKOFF[0]
|
|
200
|
-
st["probe_backoff_idx"] = 0
|
|
201
|
-
# failing → down:连续次数或持续时间任一达到
|
|
202
|
-
if st["status"] == "failing":
|
|
203
|
-
dur = _now() - (st.get("first_fail_at") or _now())
|
|
204
|
-
if n >= DOWN_AFTER_FAILURES or dur >= DOWN_AFTER_SECONDS:
|
|
205
|
-
st["status"] = "down"
|
|
206
|
-
if st["status"] == "down" and not st.get("silenced"):
|
|
207
|
-
if not st.get("alerted"):
|
|
208
|
-
st["alerted"] = True
|
|
209
|
-
log.warning("[health] ⚠️ 供应商告警:%s 不可用(连续 %d 次失败,%s)",
|
|
210
|
-
provider, n, st["last_error"][:120])
|
|
211
|
-
_persist()
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
# ------------------------------------------------- 静态死链告警(绑定层)
|
|
215
|
-
|
|
216
|
-
# ---------------------------------------------------------------- 手动操作
|
|
217
|
-
|
|
218
|
-
def silence(provider: str, minutes: int = 0):
|
|
219
|
-
"""手动静默告警(minutes=0 表示静默到恢复为止)。"""
|
|
220
|
-
with _LOCK:
|
|
221
|
-
st = _PROVIDERS.get(provider)
|
|
222
|
-
if not st:
|
|
223
|
-
return False, "未知的供应商"
|
|
224
|
-
st["silenced"] = True
|
|
225
|
-
st["silence_until"] = (_now() + minutes * 60) if minutes else SILENCE_FOREVER
|
|
226
|
-
st["alerted"] = False
|
|
227
|
-
_persist()
|
|
228
|
-
return True, ""
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def reset(provider: str):
|
|
232
|
-
"""手动清除状态视为恢复(下次失败重新计数)。"""
|
|
233
|
-
with _LOCK:
|
|
234
|
-
st = _PROVIDERS.get(provider)
|
|
235
|
-
if not st:
|
|
236
|
-
return False, "未知的供应商"
|
|
237
|
-
st.update({"status": "recovered", "consecutive_failures": 0,
|
|
238
|
-
"alerted": False, "silenced": False, "silence_until": 0,
|
|
239
|
-
"probe_next_at": 0, "recovered_at": _now()})
|
|
240
|
-
_persist()
|
|
241
|
-
return True, ""
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
# ---------------------------------------------------------------- 查询
|
|
245
|
-
|
|
246
|
-
def _effective_silenced(st):
|
|
247
|
-
"""静默是否仍有效(永久静默或未到过期时间)。"""
|
|
248
|
-
if not st.get("silenced"):
|
|
249
|
-
return False
|
|
250
|
-
until = st.get("silence_until") or 0
|
|
251
|
-
return until == SILENCE_FOREVER or until > _now()
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
def snapshot():
|
|
255
|
-
"""给 API/SSE 的完整视图。"""
|
|
256
|
-
with _LOCK:
|
|
257
|
-
out = []
|
|
258
|
-
for name, st in _PROVIDERS.items():
|
|
259
|
-
silenced = _effective_silenced(st)
|
|
260
|
-
alerting = st.get("status") == "down" and not silenced
|
|
261
|
-
out.append({
|
|
262
|
-
"provider": name,
|
|
263
|
-
"provider_id": st.get("provider_id") or "",
|
|
264
|
-
"model": st.get("model") or "",
|
|
265
|
-
"status": st.get("status") or "ok",
|
|
266
|
-
"consecutive_failures": st.get("consecutive_failures") or 0,
|
|
267
|
-
"first_fail_at": _fmt_ts(st.get("first_fail_at")),
|
|
268
|
-
"last_fail_at": _fmt_ts(st.get("last_fail_at")),
|
|
269
|
-
"last_ok_at": _fmt_ts(st.get("last_ok_at")),
|
|
270
|
-
"recovered_at": _fmt_ts(st.get("recovered_at")),
|
|
271
|
-
"last_error": st.get("last_error") or "",
|
|
272
|
-
"alerting": alerting,
|
|
273
|
-
"silenced": silenced,
|
|
274
|
-
"static": bool(st.get("static")),
|
|
275
|
-
})
|
|
276
|
-
# 稳定排序:告警中 > 故障中 > 恢复 > 正常
|
|
277
|
-
rank = {"down": 0, "failing": 1, "recovered": 2, "ok": 3}
|
|
278
|
-
out.sort(key=lambda x: (rank.get(x["status"], 9), x["provider"]))
|
|
279
|
-
return {
|
|
280
|
-
"providers": out,
|
|
281
|
-
"alerts": [p for p in out if p["alerting"]],
|
|
282
|
-
"any_alerting": any(p["alerting"] for p in out),
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def is_down(provider: str) -> bool:
|
|
287
|
-
"""链降级查询:该供应商当前是否 down(供 resolve_binding 跳过)。"""
|
|
288
|
-
with _LOCK:
|
|
289
|
-
st = _PROVIDERS.get(provider)
|
|
290
|
-
return bool(st and st.get("status") == "down")
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def down_names() -> set:
|
|
294
|
-
with _LOCK:
|
|
295
|
-
return {n for n, st in _PROVIDERS.items() if st.get("status") == "down"}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
# ---------------------------------------------------------------- 探针
|
|
299
|
-
|
|
300
|
-
def _probe_one(st):
|
|
301
|
-
"""对单个故障 provider 发轻量探活。返回 (ok, gone):
|
|
302
|
-
ok=是否恢复;gone=供应商已从配置里删除(健康记录应随之清掉)。"""
|
|
303
|
-
try:
|
|
304
|
-
from . import modelhub
|
|
305
|
-
pid = st.get("provider_id") or ""
|
|
306
|
-
model = st.get("model") or ""
|
|
307
|
-
plist = modelhub.providers()
|
|
308
|
-
provs = {p.get("name"): p for p in plist}
|
|
309
|
-
prov = provs.get(st.get("name")) or next(
|
|
310
|
-
(p for p in plist if pid and p.get("id") == pid), None)
|
|
311
|
-
if not prov:
|
|
312
|
-
return False, True # 供应商已删除:探不了也不再是「没恢复」
|
|
313
|
-
if not prov.get("api_key"):
|
|
314
|
-
return False, False # 供应商在但没密钥:无法探测,保持状态
|
|
315
|
-
if not model:
|
|
316
|
-
try:
|
|
317
|
-
names = modelhub._enabled_models(prov)
|
|
318
|
-
model = names[0]["name"] if names else ""
|
|
319
|
-
except Exception:
|
|
320
|
-
model = ""
|
|
321
|
-
if not model:
|
|
322
|
-
return False, False
|
|
323
|
-
res = modelhub.chat(prov["id"], model, "1", max_tokens=8, timeout=20)
|
|
324
|
-
return bool(res.get("ok")), False
|
|
325
|
-
except Exception as e:
|
|
326
|
-
log.debug("probe %s error: %s", st.get("name"), e)
|
|
327
|
-
return False, False
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def _prune_missing():
|
|
331
|
-
"""供应商被删除后清掉它的健康记录。
|
|
332
|
-
|
|
333
|
-
幽灵条目探不活也永远不会恢复,还会每轮把 last_fail_at 刷成「刚刚」,
|
|
334
|
-
健康页看起来像它一直在报错(Z.ai 删掉后仍显示 429 的误伤案,2026-09-18)。
|
|
335
|
-
"""
|
|
336
|
-
try:
|
|
337
|
-
from . import modelhub
|
|
338
|
-
plist = modelhub.providers()
|
|
339
|
-
except Exception:
|
|
340
|
-
return
|
|
341
|
-
ids = {p.get("id") for p in plist}
|
|
342
|
-
names = {p.get("name") for p in plist}
|
|
343
|
-
with _LOCK:
|
|
344
|
-
stale = [n for n, st in _PROVIDERS.items()
|
|
345
|
-
if not ((st.get("provider_id") and st.get("provider_id") in ids)
|
|
346
|
-
or st.get("name") in names)]
|
|
347
|
-
if stale:
|
|
348
|
-
for n in stale:
|
|
349
|
-
_PROVIDERS.pop(n, None)
|
|
350
|
-
_persist()
|
|
351
|
-
log.info("[health] 清除已删除供应商的健康记录:%s", ", ".join(stale))
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _record_probe_result(name, st, ok, gone):
|
|
355
|
-
"""只应用仍属于当前状态表的探针结果,忽略跨 init/测试的过期结果。"""
|
|
356
|
-
with _LOCK:
|
|
357
|
-
if _PROVIDERS.get(name) is not st:
|
|
358
|
-
return
|
|
359
|
-
if gone:
|
|
360
|
-
_PROVIDERS.pop(name, None)
|
|
361
|
-
_persist()
|
|
362
|
-
log.info("[health] %s 已删除,健康记录随之清除", name)
|
|
363
|
-
elif ok:
|
|
364
|
-
report_success(name)
|
|
365
|
-
log.info("[health] 探针确认 %s 已恢复", name)
|
|
366
|
-
else:
|
|
367
|
-
st["last_fail_at"] = _now()
|
|
368
|
-
_persist()
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
def _probe_loop():
|
|
372
|
-
while True:
|
|
373
|
-
try:
|
|
374
|
-
with _LOCK:
|
|
375
|
-
targets = [(name, st) for name, st in _PROVIDERS.items()
|
|
376
|
-
if not st.get("static") # 静态死链无端点可探,绑定恢复时自动解除
|
|
377
|
-
and st.get("status") in ("failing", "down")
|
|
378
|
-
and (st.get("probe_next_at") or 0) <= _now()]
|
|
379
|
-
for name, st in targets:
|
|
380
|
-
idx = int(st.get("probe_backoff_idx") or 0)
|
|
381
|
-
backoff = PROBE_BACKOFF[min(idx, len(PROBE_BACKOFF) - 1)]
|
|
382
|
-
st["probe_next_at"] = _now() + backoff
|
|
383
|
-
st["probe_backoff_idx"] = idx + 1
|
|
384
|
-
for name, st in targets:
|
|
385
|
-
ok, gone = _probe_one(st)
|
|
386
|
-
_record_probe_result(name, st, ok, gone)
|
|
387
|
-
except Exception:
|
|
388
|
-
log.exception("health probe loop error")
|
|
389
|
-
time.sleep(15) # 探针调度心跳
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""供应商健康监测与告警:连不上模型/厂商时自动降级并告警,恢复自动解除。
|
|
3
|
+
|
|
4
|
+
设计稿:docs/migration/07-token-cost.md 的延伸(2026-09-15 连载验收实战需求:
|
|
5
|
+
cavoti 网关宕机 5 小时,系统只会反复失败重试,没有主动告知「通道已不可用」)。
|
|
6
|
+
|
|
7
|
+
状态机(按 provider):
|
|
8
|
+
ok 正常(有近期成功或不活跃)
|
|
9
|
+
failing 连续失败 ≥FAILING_AFTER(进入探测模式)
|
|
10
|
+
down failing 持续 ≥DOWN_AFTER_SECONDS 或连续失败 ≥DOWN_AFTER_FAILURES
|
|
11
|
+
→ 触发告警(未静默时)
|
|
12
|
+
recovered down/failing 后再次成功 → 自动解除告警,记一笔恢复
|
|
13
|
+
|
|
14
|
+
探针策略(省 token):正常时不主动探测(靠真实调用信号);failing/down 后按
|
|
15
|
+
退避(60s→120s→300s 封顶)发轻量探活(modelhub.chat,max_tokens=8)。
|
|
16
|
+
恢复 → 回正常节奏。
|
|
17
|
+
|
|
18
|
+
手动操作:
|
|
19
|
+
silence(provider) 静默本次告警(不再提醒,直到恢复或手动 reset)
|
|
20
|
+
reset(provider) 手动清除状态视为恢复(下次失败重新计数)
|
|
21
|
+
|
|
22
|
+
线程模型:上报(runner/pipeline/modelhub 多线程调用)走锁;探针单线程 daemon。
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
FAILING_AFTER = 2 # 连续失败 ≥2 → failing(进入探测)
|
|
35
|
+
DOWN_AFTER_FAILURES = 4 # 连续失败 ≥4 → down(告警)
|
|
36
|
+
DOWN_AFTER_SECONDS = 600 # 或 failing 持续 10 分钟 → down
|
|
37
|
+
PROBE_BACKOFF = (60, 120, 300) # 探测退避秒数(封顶 300)
|
|
38
|
+
SILENCE_FOREVER = -1 # 手动静默不自动过期(恢复时才清除)
|
|
39
|
+
|
|
40
|
+
_LOCK = threading.RLock()
|
|
41
|
+
_PROVIDERS = {} # name -> 状态 dict
|
|
42
|
+
_FILE = None # init() 注入(data/provider_health.json)
|
|
43
|
+
_PROBE_THREAD = None
|
|
44
|
+
_STARTED = False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def init(data_dir=None, *, start_probe=True):
|
|
48
|
+
"""注入存储路径并从磁盘恢复状态(服务重启不丢告警上下文)。
|
|
49
|
+
|
|
50
|
+
``start_probe=False`` 供单元测试等受控场景使用,避免留下跨用例的
|
|
51
|
+
daemon 探针线程。
|
|
52
|
+
"""
|
|
53
|
+
global _FILE, _PROVIDERS, _STARTED, _PROBE_THREAD
|
|
54
|
+
with _LOCK:
|
|
55
|
+
base = Path(data_dir) if data_dir else Path(_default_dir())
|
|
56
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
_FILE = base / "provider_health.json"
|
|
58
|
+
_PROVIDERS = {}
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(_FILE.read_text(encoding="utf-8"))
|
|
61
|
+
for name, st in (data.get("providers") or {}).items():
|
|
62
|
+
if isinstance(st, dict) and st.get("name"):
|
|
63
|
+
_PROVIDERS[name] = st
|
|
64
|
+
except (OSError, json.JSONDecodeError):
|
|
65
|
+
pass
|
|
66
|
+
# 「绑定链·」静态告警胶囊已从产品移除(2026-09-18:普通用户误伤,
|
|
67
|
+
# 用户拍板)——老版本持久化的条目启动即清,别让 0.1.6 的状态阴魂不散。
|
|
68
|
+
stale = [k for k in _PROVIDERS if k.startswith("绑定链·")]
|
|
69
|
+
if stale:
|
|
70
|
+
for k in stale:
|
|
71
|
+
_PROVIDERS.pop(k, None)
|
|
72
|
+
_persist()
|
|
73
|
+
# 供应商已删除的幽灵条目启动即清;运行期新增的幽灵由探针在探活时
|
|
74
|
+
# 发现(gone 标记)随手清——探针循环里不做全量扫描,避免和测试
|
|
75
|
+
# 夹具/运行期建条目赛跑。
|
|
76
|
+
_prune_missing()
|
|
77
|
+
if start_probe and not _STARTED:
|
|
78
|
+
_STARTED = True
|
|
79
|
+
_PROBE_THREAD = threading.Thread(
|
|
80
|
+
target=_probe_loop, name="health-probe", daemon=True)
|
|
81
|
+
_PROBE_THREAD.start()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _default_dir():
|
|
85
|
+
from . import paths
|
|
86
|
+
return str(paths.DATA_DIR)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _persist():
|
|
90
|
+
# This is telemetry state: a storage failure must not turn a successful or
|
|
91
|
+
# failed model call into a task failure. Callers already hold _LOCK, but
|
|
92
|
+
# keeping the path and snapshot read under it also protects future callers.
|
|
93
|
+
with _LOCK:
|
|
94
|
+
path = _FILE
|
|
95
|
+
if path is None:
|
|
96
|
+
return
|
|
97
|
+
try:
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
tmp = path.with_suffix(".tmp")
|
|
100
|
+
tmp.write_text(
|
|
101
|
+
json.dumps({"providers": _PROVIDERS}, ensure_ascii=False, indent=2),
|
|
102
|
+
encoding="utf-8")
|
|
103
|
+
tmp.replace(path)
|
|
104
|
+
except OSError as e:
|
|
105
|
+
log.warning("[health] 无法持久化供应商健康状态到 %s: %s", path, e)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _now():
|
|
109
|
+
return time.time()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fmt_ts(epoch):
|
|
113
|
+
if not epoch:
|
|
114
|
+
return ""
|
|
115
|
+
return time.strftime("%m-%d %H:%M", time.localtime(epoch))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------- 上报入口
|
|
119
|
+
|
|
120
|
+
def report_success(provider: str):
|
|
121
|
+
"""一次真实成功调用(CLI 或 API 直连)。"""
|
|
122
|
+
if not provider:
|
|
123
|
+
return
|
|
124
|
+
with _LOCK:
|
|
125
|
+
st = _PROVIDERS.get(provider)
|
|
126
|
+
if st is None:
|
|
127
|
+
return # 没失败过就不建档(零开销)
|
|
128
|
+
if st.get("status") in ("failing", "down"):
|
|
129
|
+
st["status"] = "recovered"
|
|
130
|
+
st["recovered_at"] = _now()
|
|
131
|
+
st["consecutive_failures"] = 0
|
|
132
|
+
st["last_ok_at"] = _now()
|
|
133
|
+
st["probe_next_at"] = 0
|
|
134
|
+
st["alerted"] = False
|
|
135
|
+
st["silenced"] = False # 静默只管一次故障期;恢复后重新武装
|
|
136
|
+
st["silence_until"] = 0
|
|
137
|
+
log.info("[health] %s 已恢复(告警自动解除)", provider)
|
|
138
|
+
else:
|
|
139
|
+
st["last_ok_at"] = _now()
|
|
140
|
+
st["consecutive_failures"] = 0
|
|
141
|
+
_persist()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# run_process 包裹 Popen 启动异常的固定前缀。与 error_codes._TOOL_LAUNCH 的
|
|
145
|
+
# 判定标记同源双写(error_codes 在 L0,health 在 L1,策略禁止 L1→L0 import;
|
|
146
|
+
# 供应商侧任何报错都不会带这个前缀,单靠前缀即可零误判)。
|
|
147
|
+
_LAUNCH_FAILURE_PREFIX = "启动失败"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
151
|
+
provider_id: str = ""):
|
|
152
|
+
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
153
|
+
|
|
154
|
+
本地 CLI 启动失败(找不到可执行文件等)与供应商无关,记进来会把健康
|
|
155
|
+
供应商的评测结论作废、链被解析层过滤成死链——2026-09-30 claude 垫片被
|
|
156
|
+
清案:Bigmodel 全程健康却被连续启动失败拖进 down。只打日志提醒修本地
|
|
157
|
+
环境,不进健康台账。"""
|
|
158
|
+
if not provider:
|
|
159
|
+
return
|
|
160
|
+
if _LAUNCH_FAILURE_PREFIX in str(error or "")[:80]:
|
|
161
|
+
log.warning("[health] 本地 CLI 启动失败(与供应商 %s 无关,不记健康故障):%s",
|
|
162
|
+
provider, str(error or "")[:160])
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# A failed live call invalidates capability evidence for this upstream.
|
|
166
|
+
# Keep this hook lazy and best-effort: health telemetry must never make a
|
|
167
|
+
# model call fail, and importing evaluation at module load would create a
|
|
168
|
+
# dependency cycle during startup. ``provider_id`` is the canonical
|
|
169
|
+
# identity used by the evaluation ledger; the display name is not safe to
|
|
170
|
+
# use as a substitute because aliases can point at the same host.
|
|
171
|
+
if provider_id:
|
|
172
|
+
try:
|
|
173
|
+
from . import evaluation
|
|
174
|
+
evaluation.invalidate_provider(provider_id, model=model)
|
|
175
|
+
except Exception:
|
|
176
|
+
log.debug("[health] 无法使评测结论失效", exc_info=True)
|
|
177
|
+
with _LOCK:
|
|
178
|
+
st = _PROVIDERS.setdefault(provider, {
|
|
179
|
+
"name": provider, "provider_id": provider_id or "",
|
|
180
|
+
"model": model or "", "status": "ok",
|
|
181
|
+
"consecutive_failures": 0, "first_fail_at": 0,
|
|
182
|
+
"last_fail_at": 0, "last_ok_at": 0, "last_error": "",
|
|
183
|
+
"alerted": False, "silenced": False, "silence_until": 0,
|
|
184
|
+
"probe_next_at": 0, "probe_backoff_idx": 0,
|
|
185
|
+
"recovered_at": 0,
|
|
186
|
+
})
|
|
187
|
+
st["name"] = provider
|
|
188
|
+
if provider_id and not st.get("provider_id"):
|
|
189
|
+
st["provider_id"] = provider_id
|
|
190
|
+
if model and not st.get("model"):
|
|
191
|
+
st["model"] = model
|
|
192
|
+
n = int(st.get("consecutive_failures") or 0) + 1
|
|
193
|
+
st["consecutive_failures"] = n
|
|
194
|
+
st["last_fail_at"] = _now()
|
|
195
|
+
st["last_error"] = str(error or "")[:300]
|
|
196
|
+
if st.get("status") in ("ok", "recovered", ""):
|
|
197
|
+
st["status"] = "failing"
|
|
198
|
+
st["first_fail_at"] = _now()
|
|
199
|
+
st["probe_next_at"] = _now() + PROBE_BACKOFF[0]
|
|
200
|
+
st["probe_backoff_idx"] = 0
|
|
201
|
+
# failing → down:连续次数或持续时间任一达到
|
|
202
|
+
if st["status"] == "failing":
|
|
203
|
+
dur = _now() - (st.get("first_fail_at") or _now())
|
|
204
|
+
if n >= DOWN_AFTER_FAILURES or dur >= DOWN_AFTER_SECONDS:
|
|
205
|
+
st["status"] = "down"
|
|
206
|
+
if st["status"] == "down" and not st.get("silenced"):
|
|
207
|
+
if not st.get("alerted"):
|
|
208
|
+
st["alerted"] = True
|
|
209
|
+
log.warning("[health] ⚠️ 供应商告警:%s 不可用(连续 %d 次失败,%s)",
|
|
210
|
+
provider, n, st["last_error"][:120])
|
|
211
|
+
_persist()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------- 静态死链告警(绑定层)
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------- 手动操作
|
|
217
|
+
|
|
218
|
+
def silence(provider: str, minutes: int = 0):
|
|
219
|
+
"""手动静默告警(minutes=0 表示静默到恢复为止)。"""
|
|
220
|
+
with _LOCK:
|
|
221
|
+
st = _PROVIDERS.get(provider)
|
|
222
|
+
if not st:
|
|
223
|
+
return False, "未知的供应商"
|
|
224
|
+
st["silenced"] = True
|
|
225
|
+
st["silence_until"] = (_now() + minutes * 60) if minutes else SILENCE_FOREVER
|
|
226
|
+
st["alerted"] = False
|
|
227
|
+
_persist()
|
|
228
|
+
return True, ""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def reset(provider: str):
|
|
232
|
+
"""手动清除状态视为恢复(下次失败重新计数)。"""
|
|
233
|
+
with _LOCK:
|
|
234
|
+
st = _PROVIDERS.get(provider)
|
|
235
|
+
if not st:
|
|
236
|
+
return False, "未知的供应商"
|
|
237
|
+
st.update({"status": "recovered", "consecutive_failures": 0,
|
|
238
|
+
"alerted": False, "silenced": False, "silence_until": 0,
|
|
239
|
+
"probe_next_at": 0, "recovered_at": _now()})
|
|
240
|
+
_persist()
|
|
241
|
+
return True, ""
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------- 查询
|
|
245
|
+
|
|
246
|
+
def _effective_silenced(st):
|
|
247
|
+
"""静默是否仍有效(永久静默或未到过期时间)。"""
|
|
248
|
+
if not st.get("silenced"):
|
|
249
|
+
return False
|
|
250
|
+
until = st.get("silence_until") or 0
|
|
251
|
+
return until == SILENCE_FOREVER or until > _now()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def snapshot():
|
|
255
|
+
"""给 API/SSE 的完整视图。"""
|
|
256
|
+
with _LOCK:
|
|
257
|
+
out = []
|
|
258
|
+
for name, st in _PROVIDERS.items():
|
|
259
|
+
silenced = _effective_silenced(st)
|
|
260
|
+
alerting = st.get("status") == "down" and not silenced
|
|
261
|
+
out.append({
|
|
262
|
+
"provider": name,
|
|
263
|
+
"provider_id": st.get("provider_id") or "",
|
|
264
|
+
"model": st.get("model") or "",
|
|
265
|
+
"status": st.get("status") or "ok",
|
|
266
|
+
"consecutive_failures": st.get("consecutive_failures") or 0,
|
|
267
|
+
"first_fail_at": _fmt_ts(st.get("first_fail_at")),
|
|
268
|
+
"last_fail_at": _fmt_ts(st.get("last_fail_at")),
|
|
269
|
+
"last_ok_at": _fmt_ts(st.get("last_ok_at")),
|
|
270
|
+
"recovered_at": _fmt_ts(st.get("recovered_at")),
|
|
271
|
+
"last_error": st.get("last_error") or "",
|
|
272
|
+
"alerting": alerting,
|
|
273
|
+
"silenced": silenced,
|
|
274
|
+
"static": bool(st.get("static")),
|
|
275
|
+
})
|
|
276
|
+
# 稳定排序:告警中 > 故障中 > 恢复 > 正常
|
|
277
|
+
rank = {"down": 0, "failing": 1, "recovered": 2, "ok": 3}
|
|
278
|
+
out.sort(key=lambda x: (rank.get(x["status"], 9), x["provider"]))
|
|
279
|
+
return {
|
|
280
|
+
"providers": out,
|
|
281
|
+
"alerts": [p for p in out if p["alerting"]],
|
|
282
|
+
"any_alerting": any(p["alerting"] for p in out),
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def is_down(provider: str) -> bool:
|
|
287
|
+
"""链降级查询:该供应商当前是否 down(供 resolve_binding 跳过)。"""
|
|
288
|
+
with _LOCK:
|
|
289
|
+
st = _PROVIDERS.get(provider)
|
|
290
|
+
return bool(st and st.get("status") == "down")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def down_names() -> set:
|
|
294
|
+
with _LOCK:
|
|
295
|
+
return {n for n, st in _PROVIDERS.items() if st.get("status") == "down"}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------- 探针
|
|
299
|
+
|
|
300
|
+
def _probe_one(st):
|
|
301
|
+
"""对单个故障 provider 发轻量探活。返回 (ok, gone):
|
|
302
|
+
ok=是否恢复;gone=供应商已从配置里删除(健康记录应随之清掉)。"""
|
|
303
|
+
try:
|
|
304
|
+
from . import modelhub
|
|
305
|
+
pid = st.get("provider_id") or ""
|
|
306
|
+
model = st.get("model") or ""
|
|
307
|
+
plist = modelhub.providers()
|
|
308
|
+
provs = {p.get("name"): p for p in plist}
|
|
309
|
+
prov = provs.get(st.get("name")) or next(
|
|
310
|
+
(p for p in plist if pid and p.get("id") == pid), None)
|
|
311
|
+
if not prov:
|
|
312
|
+
return False, True # 供应商已删除:探不了也不再是「没恢复」
|
|
313
|
+
if not prov.get("api_key"):
|
|
314
|
+
return False, False # 供应商在但没密钥:无法探测,保持状态
|
|
315
|
+
if not model:
|
|
316
|
+
try:
|
|
317
|
+
names = modelhub._enabled_models(prov)
|
|
318
|
+
model = names[0]["name"] if names else ""
|
|
319
|
+
except Exception:
|
|
320
|
+
model = ""
|
|
321
|
+
if not model:
|
|
322
|
+
return False, False
|
|
323
|
+
res = modelhub.chat(prov["id"], model, "1", max_tokens=8, timeout=20)
|
|
324
|
+
return bool(res.get("ok")), False
|
|
325
|
+
except Exception as e:
|
|
326
|
+
log.debug("probe %s error: %s", st.get("name"), e)
|
|
327
|
+
return False, False
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _prune_missing():
|
|
331
|
+
"""供应商被删除后清掉它的健康记录。
|
|
332
|
+
|
|
333
|
+
幽灵条目探不活也永远不会恢复,还会每轮把 last_fail_at 刷成「刚刚」,
|
|
334
|
+
健康页看起来像它一直在报错(Z.ai 删掉后仍显示 429 的误伤案,2026-09-18)。
|
|
335
|
+
"""
|
|
336
|
+
try:
|
|
337
|
+
from . import modelhub
|
|
338
|
+
plist = modelhub.providers()
|
|
339
|
+
except Exception:
|
|
340
|
+
return
|
|
341
|
+
ids = {p.get("id") for p in plist}
|
|
342
|
+
names = {p.get("name") for p in plist}
|
|
343
|
+
with _LOCK:
|
|
344
|
+
stale = [n for n, st in _PROVIDERS.items()
|
|
345
|
+
if not ((st.get("provider_id") and st.get("provider_id") in ids)
|
|
346
|
+
or st.get("name") in names)]
|
|
347
|
+
if stale:
|
|
348
|
+
for n in stale:
|
|
349
|
+
_PROVIDERS.pop(n, None)
|
|
350
|
+
_persist()
|
|
351
|
+
log.info("[health] 清除已删除供应商的健康记录:%s", ", ".join(stale))
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _record_probe_result(name, st, ok, gone):
|
|
355
|
+
"""只应用仍属于当前状态表的探针结果,忽略跨 init/测试的过期结果。"""
|
|
356
|
+
with _LOCK:
|
|
357
|
+
if _PROVIDERS.get(name) is not st:
|
|
358
|
+
return
|
|
359
|
+
if gone:
|
|
360
|
+
_PROVIDERS.pop(name, None)
|
|
361
|
+
_persist()
|
|
362
|
+
log.info("[health] %s 已删除,健康记录随之清除", name)
|
|
363
|
+
elif ok:
|
|
364
|
+
report_success(name)
|
|
365
|
+
log.info("[health] 探针确认 %s 已恢复", name)
|
|
366
|
+
else:
|
|
367
|
+
st["last_fail_at"] = _now()
|
|
368
|
+
_persist()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _probe_loop():
|
|
372
|
+
while True:
|
|
373
|
+
try:
|
|
374
|
+
with _LOCK:
|
|
375
|
+
targets = [(name, st) for name, st in _PROVIDERS.items()
|
|
376
|
+
if not st.get("static") # 静态死链无端点可探,绑定恢复时自动解除
|
|
377
|
+
and st.get("status") in ("failing", "down")
|
|
378
|
+
and (st.get("probe_next_at") or 0) <= _now()]
|
|
379
|
+
for name, st in targets:
|
|
380
|
+
idx = int(st.get("probe_backoff_idx") or 0)
|
|
381
|
+
backoff = PROBE_BACKOFF[min(idx, len(PROBE_BACKOFF) - 1)]
|
|
382
|
+
st["probe_next_at"] = _now() + backoff
|
|
383
|
+
st["probe_backoff_idx"] = idx + 1
|
|
384
|
+
for name, st in targets:
|
|
385
|
+
ok, gone = _probe_one(st)
|
|
386
|
+
_record_probe_result(name, st, ok, gone)
|
|
387
|
+
except Exception:
|
|
388
|
+
log.exception("health probe loop error")
|
|
389
|
+
time.sleep(15) # 探针调度心跳
|