codebee 0.1.72 → 0.1.74
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 +12 -0
- package/README.md +4 -4
- package/app/core/analytics.py +38 -0
- package/app/core/bookmeta.py +0 -14
- package/app/core/contracts.py +255 -0
- package/app/core/error_codes.py +9 -0
- package/app/core/flows.py +1 -1
- package/app/core/health.py +14 -1
- package/app/core/jobs.py +54 -1
- package/app/core/manager.py +106 -17
- package/app/core/market.py +2 -2
- package/app/core/modelhub.py +104 -9
- package/app/core/openapi.py +154 -0
- package/app/core/paihang.py +8 -1
- package/app/core/pipeline.py +41 -0
- package/app/core/planner.py +2 -0
- package/app/core/plugins.py +34 -0
- package/app/core/presence.py +77 -0
- package/app/core/publish/auto.py +11 -0
- package/app/core/publish/flow.py +54 -3
- package/app/core/publish/manager.py +215 -28
- package/app/core/publish/qimao.py +30 -0
- package/app/core/retrieval.py +137 -0
- package/app/core/runner.py +85 -2
- package/app/core/skillpacks/fanqie-novel.md +66 -48
- package/app/core/skillpacks/qimao-signing.md +70 -59
- package/app/core/store.py +72 -0
- package/app/core/story_tracking.py +241 -0
- package/app/main.py +205 -48
- package/app/mcp_server.py +75 -6
- package/app/ui/app.js +390 -101
- package/app/ui/i18n.js +36 -7
- package/app/ui/index.html +8 -3
- package/app/ui/style.css +7059 -7040
- package/package.json +1 -1
package/app/core/manager.py
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import glob
|
|
9
10
|
import hashlib
|
|
10
11
|
import json
|
|
11
12
|
import os
|
|
@@ -89,10 +90,36 @@ def _safe_config_path(raw):
|
|
|
89
90
|
|
|
90
91
|
# ---------------------------------------------------------------- 检测
|
|
91
92
|
|
|
93
|
+
def _which_cli(cli):
|
|
94
|
+
"""PATH 找 CLI;Windows 上再兜底 winget 便携包两处标准落点。
|
|
95
|
+
|
|
96
|
+
winget 装的 CLI(如 Claude Code)把 exe 放进
|
|
97
|
+
%LOCALAPPDATA%\\Microsoft\\WinGet\\Packages\\<PkgId>_*\\,并把该目录写进
|
|
98
|
+
注册表用户 PATH——注册表更新后已在跑的进程(本服务)不继承,shutil.which
|
|
99
|
+
落空 → 卡片「未安装」假象,点安装又被 winget「已装无可升级」非零退出拒掉
|
|
100
|
+
(2026-09-30 实案)。Links 是便携包符号链接标准目录,Packages 按
|
|
101
|
+
<cli>.exe 通配兜另一形态;两条都只兜 which 落空的场景,零额外开销。"""
|
|
102
|
+
path = shutil.which(cli)
|
|
103
|
+
if path:
|
|
104
|
+
return path
|
|
105
|
+
if os.name != "nt":
|
|
106
|
+
return None
|
|
107
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
108
|
+
if not local:
|
|
109
|
+
return None
|
|
110
|
+
links = os.path.join(local, "Microsoft", "WinGet", "Links", cli + ".exe")
|
|
111
|
+
if os.path.isfile(links):
|
|
112
|
+
return links
|
|
113
|
+
for hit in sorted(glob.glob(os.path.join(
|
|
114
|
+
local, "Microsoft", "WinGet", "Packages", "*", cli + ".exe"))):
|
|
115
|
+
return hit
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
92
119
|
def detect_entry(entry):
|
|
93
120
|
d = entry.get("detect") or {}
|
|
94
121
|
if d.get("cli"):
|
|
95
|
-
path =
|
|
122
|
+
path = _which_cli(d["cli"])
|
|
96
123
|
return {"installed": bool(path), "detail": path or ""}
|
|
97
124
|
if d.get("exe"):
|
|
98
125
|
full = _expand(d["exe"])
|
|
@@ -562,6 +589,30 @@ def read_model(entry):
|
|
|
562
589
|
return None
|
|
563
590
|
|
|
564
591
|
|
|
592
|
+
def _atomic_write_bytes(path, data):
|
|
593
|
+
"""CLI 自家配置写盘统一走临时文件 + os.replace 原子替换。
|
|
594
|
+
|
|
595
|
+
就地 write_bytes 有「截断→重写」窗口:并行评审爆发期多个 CLI 同时被
|
|
596
|
+
拉起(bind_agent 先同步配置再 spawn),正在启动的 CLI 恰好读配置就会
|
|
597
|
+
读到半截文件——opencode 对残缺 jsonc 的反应正是打一行横幅就退出码 1
|
|
598
|
+
(2026-09-30 14:56 实案)。os.replace 原地换名,读者要么见旧要么见新。
|
|
599
|
+
"""
|
|
600
|
+
path = str(path)
|
|
601
|
+
d = os.path.dirname(path) or "."
|
|
602
|
+
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".",
|
|
603
|
+
suffix=".tmp", dir=d)
|
|
604
|
+
try:
|
|
605
|
+
with os.fdopen(fd, "wb") as fh:
|
|
606
|
+
fh.write(data)
|
|
607
|
+
os.replace(tmp, path)
|
|
608
|
+
except Exception:
|
|
609
|
+
try:
|
|
610
|
+
os.remove(tmp)
|
|
611
|
+
except OSError:
|
|
612
|
+
pass
|
|
613
|
+
raise
|
|
614
|
+
|
|
615
|
+
|
|
565
616
|
def write_model(entry, model):
|
|
566
617
|
"""写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
|
|
567
618
|
json / json-path / jsonc / yaml-line。"""
|
|
@@ -596,7 +647,7 @@ def write_model(entry, model):
|
|
|
596
647
|
# settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
|
|
597
648
|
# 内置默认」);它正是用户层覆盖的落点,缺了按需创建
|
|
598
649
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
599
|
-
|
|
650
|
+
_atomic_write_bytes(path,b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
|
|
600
651
|
if fmt == "jsonc":
|
|
601
652
|
# mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
|
|
602
653
|
# _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
|
|
@@ -607,7 +658,7 @@ def write_model(entry, model):
|
|
|
607
658
|
if not ok:
|
|
608
659
|
return {"ok": False,
|
|
609
660
|
"error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
|
|
610
|
-
|
|
661
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
611
662
|
elif fmt == "toml-line":
|
|
612
663
|
text = _read_text(path)
|
|
613
664
|
new_line = 'model = "%s"' % model
|
|
@@ -615,17 +666,17 @@ def write_model(entry, model):
|
|
|
615
666
|
text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
|
|
616
667
|
else:
|
|
617
668
|
text = text.rstrip("\n") + "\n" + new_line + "\n"
|
|
618
|
-
|
|
669
|
+
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
619
670
|
elif fmt == "toml-section":
|
|
620
671
|
text = _read_text(path, preserve_newlines=True)
|
|
621
672
|
text = _toml_write_value(text, *_dotted_key(cfg), value=model)
|
|
622
|
-
|
|
673
|
+
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
623
674
|
elif fmt == "yaml-line":
|
|
624
675
|
# newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
|
|
625
676
|
# 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
|
|
626
677
|
text = _read_text(path, preserve_newlines=True)
|
|
627
678
|
text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
|
|
628
|
-
|
|
679
|
+
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
629
680
|
elif fmt == "json-path":
|
|
630
681
|
# openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
|
|
631
682
|
# 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
|
|
@@ -635,7 +686,7 @@ def write_model(entry, model):
|
|
|
635
686
|
except Exception:
|
|
636
687
|
return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
|
|
637
688
|
_json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
|
|
638
|
-
|
|
689
|
+
_atomic_write_bytes(path,
|
|
639
690
|
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
640
691
|
else:
|
|
641
692
|
try:
|
|
@@ -646,7 +697,7 @@ def write_model(entry, model):
|
|
|
646
697
|
except Exception:
|
|
647
698
|
return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
|
|
648
699
|
data["model"] = model
|
|
649
|
-
|
|
700
|
+
_atomic_write_bytes(path,
|
|
650
701
|
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
651
702
|
return {"ok": True, "model": read_model(entry)}
|
|
652
703
|
except Exception as e:
|
|
@@ -808,7 +859,7 @@ def _sync_dsh_settings(entry, model, base_url):
|
|
|
808
859
|
if text2 != text:
|
|
809
860
|
if os.path.isfile(path):
|
|
810
861
|
shutil.copyfile(path, path + ".bak")
|
|
811
|
-
|
|
862
|
+
_atomic_write_bytes(path,text2.encode("utf-8"))
|
|
812
863
|
return None
|
|
813
864
|
except Exception as e:
|
|
814
865
|
return repr(e)
|
|
@@ -965,7 +1016,7 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
965
1016
|
if text2 != text:
|
|
966
1017
|
if os.path.isfile(path):
|
|
967
1018
|
shutil.copyfile(path, path + ".bak")
|
|
968
|
-
|
|
1019
|
+
_atomic_write_bytes(path,text2.encode("utf-8"))
|
|
969
1020
|
return None
|
|
970
1021
|
except Exception as e:
|
|
971
1022
|
return repr(e)
|
|
@@ -1182,7 +1233,7 @@ def _sync_settings_env(path, updates, remove_keys=()):
|
|
|
1182
1233
|
data["env"] = env
|
|
1183
1234
|
if os.path.isfile(path):
|
|
1184
1235
|
shutil.copyfile(path, path + ".bak")
|
|
1185
|
-
|
|
1236
|
+
_atomic_write_bytes(path,json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
1186
1237
|
return None
|
|
1187
1238
|
except Exception as e:
|
|
1188
1239
|
return repr(e)
|
|
@@ -1294,7 +1345,7 @@ def _sync_mimo_settings(entry, model, prov):
|
|
|
1294
1345
|
json.dumps("codebee/" + model))
|
|
1295
1346
|
if not ok:
|
|
1296
1347
|
return "mimocode.jsonc 结构异常,未能写入 model(已避免覆盖)"
|
|
1297
|
-
|
|
1348
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1298
1349
|
return None
|
|
1299
1350
|
except OSError as e:
|
|
1300
1351
|
return "写入 mimocode.jsonc 失败:%s" % str(e)[:80]
|
|
@@ -1374,7 +1425,7 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1374
1425
|
json.dumps(top_perms))
|
|
1375
1426
|
if os.path.isfile(path):
|
|
1376
1427
|
shutil.copyfile(path, path + ".bak")
|
|
1377
|
-
|
|
1428
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1378
1429
|
except Exception as e:
|
|
1379
1430
|
errs.append("%s: %r" % (path, e))
|
|
1380
1431
|
return ";".join(errs) or None
|
|
@@ -1448,7 +1499,7 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1448
1499
|
new_text = top + "\n" + body + "\n" + tables
|
|
1449
1500
|
else:
|
|
1450
1501
|
new_text = (body + "\n" if body else "") + managed
|
|
1451
|
-
|
|
1502
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1452
1503
|
except Exception as e:
|
|
1453
1504
|
errs.append("%s: %r" % (path, e))
|
|
1454
1505
|
return ";".join(errs) or None
|
|
@@ -1489,7 +1540,7 @@ def _pi_write_json(path, mutate):
|
|
|
1489
1540
|
if os.path.isfile(path):
|
|
1490
1541
|
shutil.copyfile(path, path + ".bak")
|
|
1491
1542
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1492
|
-
|
|
1543
|
+
_atomic_write_bytes(path,
|
|
1493
1544
|
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
1494
1545
|
return None
|
|
1495
1546
|
|
|
@@ -1907,6 +1958,34 @@ def _open_when_ready(port, url, log_path, timeout=30):
|
|
|
1907
1958
|
|
|
1908
1959
|
# ---------------------------------------------------------------- 安装/升级
|
|
1909
1960
|
|
|
1961
|
+
# winget「包已装且无可用升级」退出码(有符号 -1978335189 / 无符号 2316632107)
|
|
1962
|
+
_WINGET_NOOP_EXIT = 0x8A15002B
|
|
1963
|
+
# 双语结论标记:winget 对已装包重复 install 会转 upgrade,无可升级即打这些话
|
|
1964
|
+
_WINGET_NOOP_MARKERS = ("找不到可用的升级", "没有可用的较新的包版本",
|
|
1965
|
+
"No available upgrade", "No newer package versions")
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
def _winget_already_ok(cmd, op, res):
|
|
1969
|
+
"""winget install/upgrade 撞「已装且无可升级」时的良性判定。
|
|
1970
|
+
|
|
1971
|
+
该场景命令非零退出,但目标态(已安装)本就达成——按失败走会触发 AI
|
|
1972
|
+
修复白烧一轮诊断(2026-09-30 Claude Code 实案:检测漏装 + 点安装 =
|
|
1973
|
+
假失败 + 假修复)。只认显式「无事可做」的退出码/结论话术,且调用方
|
|
1974
|
+
还要复检 installed 才按成功收口。"""
|
|
1975
|
+
if res.get("ok") or op not in ("install", "upgrade"):
|
|
1976
|
+
return False
|
|
1977
|
+
if not str(cmd or "").strip().lower().startswith("winget"):
|
|
1978
|
+
return False
|
|
1979
|
+
code = res.get("exit_code")
|
|
1980
|
+
try:
|
|
1981
|
+
if code is not None and (int(code) & 0xFFFFFFFF) == _WINGET_NOOP_EXIT:
|
|
1982
|
+
return True
|
|
1983
|
+
except (TypeError, ValueError):
|
|
1984
|
+
pass
|
|
1985
|
+
out = "%s\n%s" % (res.get("stdout") or "", res.get("stderr") or "")
|
|
1986
|
+
return any(m in out for m in _WINGET_NOOP_MARKERS)
|
|
1987
|
+
|
|
1988
|
+
|
|
1910
1989
|
def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
1911
1990
|
"""执行 install/upgrade/uninstall 命令(由任务执行器异步运行,日志实时落盘)。"""
|
|
1912
1991
|
if op == "uninstall":
|
|
@@ -1923,17 +2002,27 @@ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
|
1923
2002
|
% (op, entry["id"])}
|
|
1924
2003
|
res = runner.run_process(shell_cmd=cmd, cwd=str(paths.ROOT),
|
|
1925
2004
|
timeout=1800, cancel_event=cancel_event, log_path=log_path)
|
|
2005
|
+
winget_noop = _winget_already_ok(cmd, op, res)
|
|
1926
2006
|
detect_all(force=True)
|
|
1927
2007
|
with _LOCK:
|
|
1928
2008
|
_STATE["versions"].pop(entry["id"], None)
|
|
2009
|
+
if winget_noop and detect_entry(entry)["installed"]:
|
|
2010
|
+
# winget 说「已装且无可升级」、复检也在装:install 语义(确保在装)已达成
|
|
2011
|
+
refresh_update_async(entry)
|
|
2012
|
+
return {"ok": True, "exit_code": res["exit_code"], "command": cmd,
|
|
2013
|
+
"error": "",
|
|
2014
|
+
"note": "winget 报已安装且无可用升级,复检确认本机在装"}
|
|
1929
2015
|
if res["ok"]:
|
|
1930
2016
|
# 安装/升级成功即作废该条目的更新检查缓存并后台复检:否则 10 分钟 TTL
|
|
1931
2017
|
# 内徽章仍显示「有新版本」,诱导同版本重装(重装易撞 EBUSY 文件锁,
|
|
1932
2018
|
# 2026-09-18 dsh 案)。
|
|
1933
2019
|
refresh_update_async(entry)
|
|
2020
|
+
tail = runner.clean_cli_text(res["stderr"])[-800:]
|
|
2021
|
+
if not tail and not res["ok"]:
|
|
2022
|
+
# winget 把结论打在 stdout(stderr 恒空),只回「退出码 N」零信息量
|
|
2023
|
+
tail = runner.clean_cli_text(res["stdout"])[-800:]
|
|
1934
2024
|
return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
|
|
1935
|
-
"error": "" if res["ok"] else (
|
|
1936
|
-
or "退出码 %s" % res["exit_code"])}
|
|
2025
|
+
"error": "" if res["ok"] else (tail or "退出码 %s" % res["exit_code"])}
|
|
1937
2026
|
|
|
1938
2027
|
|
|
1939
2028
|
def refresh_update_async(entry):
|
package/app/core/market.py
CHANGED
|
@@ -78,9 +78,9 @@ BUILTIN_PACKS = [
|
|
|
78
78
|
# skills 未来新增内置包时落「未分类」兜底,目录仍然可见、不可安装)
|
|
79
79
|
_BUILTIN_META = {
|
|
80
80
|
"fanqie-novel": {"category": "节奏爽点",
|
|
81
|
-
"desc": "
|
|
81
|
+
"desc": "番茄写作与分段数据诊断:后台指标只作假设,历史更新数字和算法推断不作硬规则。"},
|
|
82
82
|
"qimao-signing": {"category": "流程规范",
|
|
83
|
-
"desc": "
|
|
83
|
+
"desc": "七猫写作经验复盘:开篇事件、人物选择与拒稿证据;历史点位和平台政策不是硬门槛。"},
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
|
package/app/core/modelhub.py
CHANGED
|
@@ -74,6 +74,9 @@ _PROTOCOL_AUTO = "auto"
|
|
|
74
74
|
_PROTOCOL_CHOICES = _PROTOCOLS + (_PROTOCOL_AUTO,)
|
|
75
75
|
# auto 挑主协议时的偏好顺序:只挑「实测过的」wire(wire_caps),不猜。
|
|
76
76
|
_WIRE_PREFERENCE = ("anthropic", "openai")
|
|
77
|
+
# 新鲜失败(fresh + failed)评测条目的重探间隔:失败探针会刷新 evaluated_at,
|
|
78
|
+
# 实际效果=固定 15 分钟退避一次,而不是把链锁死到 24h TTL(0930 公司OpenAI 案)。
|
|
79
|
+
_FAILED_REPROBE_AFTER = 15 * 60
|
|
77
80
|
|
|
78
81
|
|
|
79
82
|
def _load():
|
|
@@ -264,7 +267,12 @@ def _model_payload_ok(protocol, data, wire_api=""):
|
|
|
264
267
|
|
|
265
268
|
|
|
266
269
|
def _evaluation_is_usable(provider_id, model=""):
|
|
267
|
-
"""Unknown evidence is allowed; stale evidence is never a routing green light.
|
|
270
|
+
"""Unknown evidence is allowed; stale evidence is never a routing green light.
|
|
271
|
+
|
|
272
|
+
新鲜失败(fresh + failed)不再硬卡到 24h TTL:隔 ``_FAILED_REPROBE_AFTER``
|
|
273
|
+
秒就地重探一次。失败的探针会刷新 evaluated_at,自然形成固定间隔的退避
|
|
274
|
+
节流——2026-09-30 公司OpenAI 案:一条 10:39 的失败探针把 codex 链锁到
|
|
275
|
+
次日同时刻,网关恢复后也没有任何自动回探路径(重探只对 stale 触发)。"""
|
|
268
276
|
try:
|
|
269
277
|
from . import evaluation
|
|
270
278
|
for kind, name in (("provider", ""), ("model", model)):
|
|
@@ -280,6 +288,14 @@ def _evaluation_is_usable(provider_id, model=""):
|
|
|
280
288
|
return bool(probe and probe.get("ok") and probe.get("fresh"))
|
|
281
289
|
if entry and (not entry.get("ok") or entry.get("status") not in
|
|
282
290
|
("passed", "reachable", "reachable_unverified")):
|
|
291
|
+
try:
|
|
292
|
+
age = time.time() - float(entry.get("evaluated_at") or 0)
|
|
293
|
+
except (TypeError, ValueError):
|
|
294
|
+
age = 0.0
|
|
295
|
+
if age >= _FAILED_REPROBE_AFTER:
|
|
296
|
+
probe = (test_model(provider_id, model) if kind == "model" and model
|
|
297
|
+
else test_provider(provider_id))
|
|
298
|
+
return bool(probe and probe.get("ok") and probe.get("fresh"))
|
|
283
299
|
return False
|
|
284
300
|
except Exception:
|
|
285
301
|
return True
|
|
@@ -2433,9 +2449,43 @@ def bindable_protocols(agent_kind_or_id):
|
|
|
2433
2449
|
_LOCAL_CRED_ONLY_TARGETS = ("gemini-cli", "codebuddy", "trae-agent")
|
|
2434
2450
|
|
|
2435
2451
|
|
|
2452
|
+
def _dead(reasons, text):
|
|
2453
|
+
"""死因速记去重追加(同供应商多条链目只留一份,防文案刷屏)。"""
|
|
2454
|
+
if text not in reasons:
|
|
2455
|
+
reasons.append(text)
|
|
2456
|
+
|
|
2457
|
+
|
|
2458
|
+
# 死因速记(进程内):resolve_binding 判空链时按 CLI 记最近一次逐条死因,
|
|
2459
|
+
# binding_dead_msg 据此把「真实死因」钉进失败/告警文案——0930 假死链案:
|
|
2460
|
+
# 文案只列「停用/删除/无密钥/模型停用」四种配置猜测,真凶(健康事件作废、
|
|
2461
|
+
# 探针失败、协议不匹配)一个都不在文案里,用户看着「明明有可用模型」无从对账。
|
|
2462
|
+
_DEAD_REASONS = {}
|
|
2463
|
+
_DEAD_REASONS_TTL = 15 * 60.0
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def _note_dead_reasons(cli_id, items):
|
|
2467
|
+
with _LOCK:
|
|
2468
|
+
_DEAD_REASONS[str(cli_id or "")] = {
|
|
2469
|
+
"at": time.time(),
|
|
2470
|
+
"items": [str(x)[:90] for x in (items or []) if str(x).strip()][:6],
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
|
|
2474
|
+
def last_dead_reasons(cli_id):
|
|
2475
|
+
"""该 CLI 最近一次链解析死因(超过 TTL 视为过期不复述,防陈旧误导)。"""
|
|
2476
|
+
try:
|
|
2477
|
+
with _LOCK:
|
|
2478
|
+
rec = _DEAD_REASONS.get(str(cli_id or ""))
|
|
2479
|
+
fresh = (rec is not None
|
|
2480
|
+
and time.time() - float(rec.get("at") or 0) <= _DEAD_REASONS_TTL)
|
|
2481
|
+
return list(rec.get("items") or []) if fresh else []
|
|
2482
|
+
except Exception:
|
|
2483
|
+
return []
|
|
2484
|
+
|
|
2485
|
+
|
|
2436
2486
|
def binding_dead_msg(cli_id):
|
|
2437
2487
|
"""死链失败/告警文案(pipeline 死链闸门与本模块 sync 共用):说明为什么
|
|
2438
|
-
不回落本机默认 + 该 CLI
|
|
2488
|
+
不回落本机默认 + 该 CLI 需要什么协议的供应商 + 最近一次解析的真实死因。"""
|
|
2439
2489
|
try:
|
|
2440
2490
|
protos = bindable_protocols(cli_id)
|
|
2441
2491
|
except Exception:
|
|
@@ -2444,8 +2494,12 @@ def binding_dead_msg(cli_id):
|
|
|
2444
2494
|
return ("该 CLI 暂不支持绑定链(凭据注入尚未适配,使用 CLI 本机登录态与"
|
|
2445
2495
|
"默认模型);请勿为其配置供应商链")
|
|
2446
2496
|
hint = "该 CLI 仅接受 %s 协议的已启用供应商;" % "、".join(protos)
|
|
2447
|
-
|
|
2448
|
-
|
|
2497
|
+
msg = ("绑定链全部失效(链上供应商已停用/删除/无密钥,或模型已停用),"
|
|
2498
|
+
"本步判失败、不回落 CLI 本机默认——%s请在「模型调度(可选)」页为该 CLI 指定已启用的供应商" % hint)
|
|
2499
|
+
reasons = last_dead_reasons(cli_id)
|
|
2500
|
+
if reasons:
|
|
2501
|
+
msg += "。最近一次解析死因:" + ";".join(reasons)
|
|
2502
|
+
return msg
|
|
2449
2503
|
|
|
2450
2504
|
|
|
2451
2505
|
def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
@@ -2485,6 +2539,7 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2485
2539
|
down_set = health.down_names()
|
|
2486
2540
|
except Exception:
|
|
2487
2541
|
down_set = set()
|
|
2542
|
+
dead_reasons = [] # 逐条死因速记:解析为空时喂给 binding_dead_msg(0930 假死链案)
|
|
2488
2543
|
for item in chain:
|
|
2489
2544
|
model = (item.get("model") or "").strip()
|
|
2490
2545
|
pid = (item.get("provider_id") or "").strip()
|
|
@@ -2497,20 +2552,38 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2497
2552
|
"no_cred": True})
|
|
2498
2553
|
continue
|
|
2499
2554
|
prov = provs.get(pid)
|
|
2500
|
-
|
|
2501
|
-
|
|
2555
|
+
label = (prov or {}).get("name") or pid
|
|
2556
|
+
if not prov:
|
|
2557
|
+
_dead(dead_reasons, "%s:供应商已删除" % label)
|
|
2558
|
+
continue
|
|
2559
|
+
if not prov.get("enabled", True):
|
|
2560
|
+
_dead(dead_reasons, "%s:供应商已停用" % label)
|
|
2561
|
+
continue
|
|
2562
|
+
if not prov.get("api_key"):
|
|
2563
|
+
_dead(dead_reasons, "%s:无可用密钥" % label)
|
|
2564
|
+
continue
|
|
2502
2565
|
ep = _entry_endpoint(prov, allowed)
|
|
2503
2566
|
if not ep:
|
|
2504
|
-
|
|
2567
|
+
proto = prov.get("protocol") or "auto"
|
|
2568
|
+
_dead(dead_reasons, "%s:协议不匹配(%s,该 CLI 仅接受 %s)"
|
|
2569
|
+
% (label, proto if proto != "auto" else "auto 未探出可用 wire",
|
|
2570
|
+
"、".join(allowed)))
|
|
2571
|
+
continue
|
|
2505
2572
|
if prov.get("name") in down_set:
|
|
2573
|
+
_dead(dead_reasons, "%s:健康监测判定 down" % label)
|
|
2506
2574
|
continue # 健康监测判定 down:跳过,省掉无效等待
|
|
2507
2575
|
if not _evaluation_is_usable(pid, model):
|
|
2576
|
+
_dead(dead_reasons, "%s:评测结论不可用(健康事件作废或探针失败)"
|
|
2577
|
+
% label)
|
|
2508
2578
|
continue # 旧评测结论已过期/被健康事件作废,不能冒充绿灯
|
|
2509
2579
|
if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
|
|
2580
|
+
_dead(dead_reasons, "%s:chat wire 被 codex 剔除(codex 仅讲 responses)"
|
|
2581
|
+
% label)
|
|
2510
2582
|
continue # codex 0.154+ 只讲 responses wire:chat-only 供应商在起跑前
|
|
2511
2583
|
# 就剔除(此前撞了才冷却 30 分钟,每轮白烧一次注定失败的
|
|
2512
2584
|
# 尝试——2026-09-17 续4 连载 c35 实测)
|
|
2513
2585
|
if model and not _model_bindable(prov, model):
|
|
2586
|
+
_dead(dead_reasons, "%s:模型 %s 已停用/删除" % (label, model))
|
|
2514
2587
|
continue # 模型被停用/删除:该条跳过(2026-09-15 告警弹框「禁用该模型」)
|
|
2515
2588
|
# 多 KEY:同一厂商按 KEY 展开成多条,顺序即调用顺序。欠费的 KEY 被
|
|
2516
2589
|
# 冷却跳过(切备用),全冷却时仍留一条顶上——降级复用既有尝试循环。
|
|
@@ -2524,6 +2597,7 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2524
2597
|
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
2525
2598
|
break
|
|
2526
2599
|
if not entries:
|
|
2600
|
+
_note_dead_reasons(agent_kind_or_id, dead_reasons)
|
|
2527
2601
|
return None
|
|
2528
2602
|
# 显式难度路由开启时,解析层就是唯一排序真源;关闭时保持手工链顺序。
|
|
2529
2603
|
if routing and tier in ("easy", "hard"):
|
|
@@ -2553,16 +2627,37 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2553
2627
|
if not pid:
|
|
2554
2628
|
return None
|
|
2555
2629
|
prov = provs.get(pid)
|
|
2556
|
-
|
|
2630
|
+
single_reasons = []
|
|
2631
|
+
label = (prov or {}).get("name") or pid
|
|
2632
|
+
if not prov:
|
|
2633
|
+
_dead(single_reasons, "%s:供应商已删除" % label)
|
|
2634
|
+
_note_dead_reasons(agent_kind_or_id, single_reasons)
|
|
2635
|
+
return None
|
|
2636
|
+
if not prov.get("enabled", True):
|
|
2637
|
+
_dead(single_reasons, "%s:供应商已停用" % label)
|
|
2638
|
+
_note_dead_reasons(agent_kind_or_id, single_reasons)
|
|
2639
|
+
return None
|
|
2640
|
+
if not prov.get("api_key"):
|
|
2641
|
+
_dead(single_reasons, "%s:无可用密钥" % label)
|
|
2642
|
+
_note_dead_reasons(agent_kind_or_id, single_reasons)
|
|
2557
2643
|
return None
|
|
2558
2644
|
ep = _entry_endpoint(prov, allowed)
|
|
2559
2645
|
if not ep:
|
|
2646
|
+
proto = prov.get("protocol") or "auto"
|
|
2647
|
+
_dead(single_reasons, "%s:协议不匹配(%s,该 CLI 仅接受 %s)"
|
|
2648
|
+
% (label, proto if proto != "auto" else "auto 未探出可用 wire",
|
|
2649
|
+
"、".join(allowed)))
|
|
2650
|
+
_note_dead_reasons(agent_kind_or_id, single_reasons)
|
|
2560
2651
|
return None # google 只登记;dsh 只接受 OpenAI 兼容端点;未适配的不硬塞
|
|
2561
2652
|
if not _evaluation_is_usable(pid):
|
|
2653
|
+
_dead(single_reasons, "%s:评测结论不可用(健康事件作废或探针失败)" % label)
|
|
2654
|
+
_note_dead_reasons(agent_kind_or_id, single_reasons)
|
|
2562
2655
|
return None
|
|
2563
2656
|
if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
|
|
2657
|
+
_dead(single_reasons, "%s:chat wire 被 codex 剔除(codex 仅讲 responses)" % label)
|
|
2658
|
+
_note_dead_reasons(agent_kind_or_id, single_reasons)
|
|
2564
2659
|
return None # codex 0.154+ 只讲 responses:chat-only 供应商直接判不可绑
|
|
2565
|
-
|
|
2660
|
+
# (解析为空 → 死链闸门/路由降权接手,不浪费 CLI 尝试)
|
|
2566
2661
|
names = [m["name"] for m in _enabled_models(prov)]
|
|
2567
2662
|
model = prov.get("model_" + tier) or "" if (routing and tier) else ""
|
|
2568
2663
|
if not model:
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""OpenAPI 3 contract for the stable headless CodeBee integrations."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def document():
|
|
5
|
+
schemas = {
|
|
6
|
+
"Error": {"type": "object", "required": ["error"], "properties": {
|
|
7
|
+
"error": {"type": "string"}, "control": {"$ref": "#/components/schemas/Control"}}},
|
|
8
|
+
"Control": {"type": "object", "properties": {
|
|
9
|
+
"mode": {"type": "string", "enum": ["free", "held"]},
|
|
10
|
+
"mine": {"type": "boolean"}, "holder": {"type": "string"},
|
|
11
|
+
"expires_in": {"type": "integer", "minimum": 0}}},
|
|
12
|
+
"TaskCreate": {"type": "object", "required": ["goal"], "properties": {
|
|
13
|
+
"type": {"type": "string"}, "title": {"type": "string"},
|
|
14
|
+
"goal": {"type": "string", "maxLength": 4000}, "workdir": {"type": "string"},
|
|
15
|
+
"acceptance_criteria": {"type": "array", "maxItems": 40,
|
|
16
|
+
"items": {"type": "string", "maxLength": 500}},
|
|
17
|
+
"approval_required": {"type": "boolean"}}},
|
|
18
|
+
"TaskResult": {"type": "object", "required": ["task_id", "run_id"], "properties": {
|
|
19
|
+
"task_id": {"type": "string"}, "run_id": {"type": "string"}}},
|
|
20
|
+
"Evidence": {"type": "object", "required": ["status"], "properties": {
|
|
21
|
+
"id": {"type": "string"}, "criterion": {"type": "string", "maxLength": 500},
|
|
22
|
+
"status": {"type": "string", "enum": ["passed", "failed", "unknown", "not_evaluated"]},
|
|
23
|
+
"summary": {"type": "string", "maxLength": 2000},
|
|
24
|
+
"source": {"type": "string", "maxLength": 80}, "created_at": {"type": "string"}}},
|
|
25
|
+
"Contract": {"type": "object", "properties": {
|
|
26
|
+
"task_id": {"type": "string"},
|
|
27
|
+
"acceptance_criteria": {"type": "array", "items": {"type": "string"}},
|
|
28
|
+
"evidence": {"type": "array", "items": {"$ref": "#/components/schemas/Evidence"}},
|
|
29
|
+
"approval_required": {"type": "boolean"}, "approval": {"type": "object"},
|
|
30
|
+
"blocked_reason": {"type": "string"}, "completion_receipt": {"type": "object"}}},
|
|
31
|
+
"StoryChapter": {"type": "object", "properties": {
|
|
32
|
+
"op": {"type": "string"},
|
|
33
|
+
"chapter": {"type": "integer", "minimum": 1, "maximum": 5000},
|
|
34
|
+
"outline": {"type": "string", "maxLength": 12000}, "prose": {"type": "string"},
|
|
35
|
+
"facts": {"type": "array", "items": {"type": "string"}},
|
|
36
|
+
"characters": {"type": "array", "items": {"type": "string"}},
|
|
37
|
+
"timeline": {"type": "array", "items": {"type": "string"}},
|
|
38
|
+
"foreshadowing": {"type": "array", "items": {"type": "object"}},
|
|
39
|
+
"next_promises": {"type": "array", "items": {"type": "string"}},
|
|
40
|
+
"author_truth": {"type": "array", "items": {"type": "string"}},
|
|
41
|
+
"reader_known": {"type": "array", "items": {"type": "string"}}},
|
|
42
|
+
"oneOf": [
|
|
43
|
+
{"required": ["op"], "properties": {"op": {"const": "init"}}},
|
|
44
|
+
{"required": ["chapter", "outline", "prose"]},
|
|
45
|
+
]},
|
|
46
|
+
"StoryTrackingResult": {"type": "object", "properties": {
|
|
47
|
+
"state": {"type": "object"}, "check": {"type": "object"}}},
|
|
48
|
+
"Presence": {"type": "object", "required": ["agent_id"], "properties": {
|
|
49
|
+
"agent_id": {"type": "string", "maxLength": 100}, "label": {"type": "string", "maxLength": 120},
|
|
50
|
+
"capabilities": {"type": "array", "maxItems": 30,
|
|
51
|
+
"items": {"type": "string", "maxLength": 80}},
|
|
52
|
+
"task_id": {"type": "string", "maxLength": 100}, "status": {"type": "string", "maxLength": 30},
|
|
53
|
+
"metadata": {"type": "object"}}},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
def body(schema):
|
|
57
|
+
return {"required": True, "content": {"application/json": {"schema": schema}}}
|
|
58
|
+
|
|
59
|
+
def response(description, schema=None):
|
|
60
|
+
out = {"description": description}
|
|
61
|
+
if schema is not None:
|
|
62
|
+
out["content"] = {"application/json": {"schema": schema}}
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
ref = lambda name: {"$ref": "#/components/schemas/" + name}
|
|
66
|
+
security = [{"codebeeToken": []}, {"codebeeQueryToken": []}]
|
|
67
|
+
errors = {code: response(desc, ref("Error")) for code, desc in (
|
|
68
|
+
("400", "请求无效"), ("401", "缺少或错误的访问令牌"),
|
|
69
|
+
("404", "资源不存在"), ("409", "状态冲突或审批未完成"),
|
|
70
|
+
("423", "其他设备持有控制权"))}
|
|
71
|
+
ok = response("成功", {"type": "object", "properties": {"ok": {"type": "boolean"}}})
|
|
72
|
+
|
|
73
|
+
paths = {
|
|
74
|
+
"/api/tasks": {"post": {"summary": "创建并启动任务", "security": security,
|
|
75
|
+
"requestBody": body(ref("TaskCreate")),
|
|
76
|
+
"responses": {"200": response("已排队", ref("TaskResult")),
|
|
77
|
+
"503": response("运行记录或入队失败", ref("Error")), **errors}}},
|
|
78
|
+
"/api/a2a/tasks": {"post": {"summary": "A2A-lite 创建任务", "security": security,
|
|
79
|
+
"requestBody": body(ref("TaskCreate")),
|
|
80
|
+
"responses": {"200": response("已排队", ref("TaskResult")), "400": errors["400"],
|
|
81
|
+
"401": errors["401"], "423": errors["423"]}}},
|
|
82
|
+
"/api/a2a/tasks/{task_id}": {"get": {"summary": "A2A-lite 读取任务状态", "security": security,
|
|
83
|
+
"responses": {"200": response("任务、运行和契约", {"type": "object"}),
|
|
84
|
+
"401": errors["401"], "404": errors["404"]}}},
|
|
85
|
+
"/api/tasks/{task_id}/detail": {"get": {"summary": "读取任务详情和契约", "security": security,
|
|
86
|
+
"responses": {"200": response("任务详情", {"type": "object"}), "401": errors["401"], "404": errors["404"]}}},
|
|
87
|
+
"/api/tasks/{task_id}/contract": {
|
|
88
|
+
"get": {"summary": "读取任务契约", "security": security,
|
|
89
|
+
"responses": {"200": response("契约", ref("Contract")), "401": errors["401"], "404": errors["404"]}},
|
|
90
|
+
"post": {"summary": "更新契约、追加证据或审批任务", "security": security,
|
|
91
|
+
"requestBody": body({"type": "object", "properties": {
|
|
92
|
+
"op": {"type": "string", "enum": ["update", "create", "evidence", "approve", "reject", "blocked"]},
|
|
93
|
+
"acceptance_criteria": {"type": "array", "items": {"type": "string"}},
|
|
94
|
+
"approval_required": {"type": "boolean"}, "evidence": ref("Evidence"),
|
|
95
|
+
"note": {"type": "string", "maxLength": 1000}, "reason": {"type": "string", "maxLength": 1000}}}),
|
|
96
|
+
"responses": {"200": response("契约已更新", {"type": "object", "properties": {"contract": ref("Contract")}}), **errors}}},
|
|
97
|
+
"/api/tasks/{task_id}/story-tracking": {
|
|
98
|
+
"get": {"summary": "读取小说追踪状态", "security": security,
|
|
99
|
+
"responses": {"200": response("追踪状态", ref("StoryTrackingResult")), "401": errors["401"],
|
|
100
|
+
"404": errors["404"], "409": errors["409"]}},
|
|
101
|
+
"post": {"summary": "提交章节结构化状态", "security": security, "requestBody": body(ref("StoryChapter")),
|
|
102
|
+
"responses": {"200": response("追踪状态已更新", ref("StoryTrackingResult")), **errors}}},
|
|
103
|
+
"/api/agents/presence": {
|
|
104
|
+
"get": {"summary": "Agent 在线状态", "security": security,
|
|
105
|
+
"responses": {"200": response("在线 Agent 列表", {"type": "object"}), "401": errors["401"]}},
|
|
106
|
+
"post": {"summary": "Agent 心跳", "security": security, "requestBody": body(ref("Presence")),
|
|
107
|
+
"responses": {"200": response("心跳已记录", {"type": "object"}), "400": errors["400"],
|
|
108
|
+
"401": errors["401"], "423": errors["423"]}}},
|
|
109
|
+
"/api/retrieval/search": {"get": {"summary": "本地检索", "security": security,
|
|
110
|
+
"parameters": [{"name": "q", "in": "query", "required": True,
|
|
111
|
+
"schema": {"type": "string", "maxLength": 500}},
|
|
112
|
+
{"name": "limit", "in": "query",
|
|
113
|
+
"schema": {"type": "integer", "minimum": 1, "maximum": 50}}],
|
|
114
|
+
"responses": {"200": response("检索结果", {"type": "object"}), "400": errors["400"], "401": errors["401"]}}},
|
|
115
|
+
"/api/retrieval/index": {"post": {"summary": "为配置工作区/任务目录建立本地检索索引", "security": security,
|
|
116
|
+
"requestBody": body({"type": "object", "properties": {
|
|
117
|
+
"source": {"type": "string"}, "text": {"type": "string"}, "metadata": {"type": "object"},
|
|
118
|
+
"doc_id": {"type": "string"}, "root": {"type": "string"}, "task_id": {"type": "string"},
|
|
119
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 500}}}),
|
|
120
|
+
"responses": {"200": response("索引已更新", {"type": "object"}), "400": errors["400"],
|
|
121
|
+
"401": errors["401"], "404": errors["404"], "423": errors["423"]}}},
|
|
122
|
+
"/api/analytics": {"get": {"summary": "任务成本质量分析", "security": security,
|
|
123
|
+
"responses": {"200": response("分析摘要", {"type": "object"}), "401": errors["401"]}}},
|
|
124
|
+
"/api/runs/{run_id}": {"get": {"summary": "读取运行状态", "security": security,
|
|
125
|
+
"responses": {"200": response("运行状态", {"type": "object"}), "401": errors["401"], "404": errors["404"]}}},
|
|
126
|
+
"/api/runs/{run_id}/resume_now": {"post": {"summary": "立即重试运行", "security": security,
|
|
127
|
+
"responses": {"200": ok, "400": errors["400"], "401": errors["401"], "404": errors["404"], "423": errors["423"]}}},
|
|
128
|
+
"/api/control": {
|
|
129
|
+
"get": {"summary": "读取当前控制权", "security": security,
|
|
130
|
+
"responses": {"200": response("控制权状态", {"type": "object"}), "401": errors["401"]}},
|
|
131
|
+
"post": {"summary": "接管或释放控制权", "security": security,
|
|
132
|
+
"requestBody": body({"type": "object", "required": ["action"], "properties": {
|
|
133
|
+
"action": {"type": "string", "enum": ["acquire", "release"]},
|
|
134
|
+
"force": {"type": "boolean"}}}),
|
|
135
|
+
"responses": {"200": response("控制权已更新", {"type": "object"}),
|
|
136
|
+
"400": errors["400"], "401": errors["401"], "423": errors["423"]}}},
|
|
137
|
+
"/api/control/heartbeat": {
|
|
138
|
+
"get": {"summary": "续期当前控制权", "security": security,
|
|
139
|
+
"responses": {"200": response("控制权状态", {"type": "object"}), "401": errors["401"]}},
|
|
140
|
+
"post": {"summary": "续期当前控制权", "security": security,
|
|
141
|
+
"responses": {"200": response("控制权状态", {"type": "object"}), "401": errors["401"]}}},
|
|
142
|
+
"/api/openapi.json": {"get": {"summary": "读取 OpenAPI 文档", "security": security,
|
|
143
|
+
"responses": {"200": response("OpenAPI 文档", {"type": "object"}), "401": errors["401"]}}},
|
|
144
|
+
"/api/health": {"get": {"summary": "读取服务健康状态", "security": security,
|
|
145
|
+
"responses": {"200": response("健康状态", {"type": "object"}), "401": errors["401"]}}},
|
|
146
|
+
"/api/state": {"get": {"summary": "读取任务状态聚合", "security": security,
|
|
147
|
+
"responses": {"200": response("状态聚合", {"type": "object"}), "401": errors["401"]}}},
|
|
148
|
+
}
|
|
149
|
+
return {"openapi": "3.0.3", "info": {"title": "CodeBee API", "version": "1.0",
|
|
150
|
+
"description": "本机/局域网 API。远程请求需 X-CodeBee-Token 或 token 查询参数;本机 loopback 默认免令牌。"},
|
|
151
|
+
"servers": [{"url": "/"}], "components": {"securitySchemes": {
|
|
152
|
+
"codebeeToken": {"type": "apiKey", "in": "header", "name": "X-CodeBee-Token"},
|
|
153
|
+
"codebeeQueryToken": {"type": "apiKey", "in": "query", "name": "token"}},
|
|
154
|
+
"schemas": schemas}, "paths": paths}
|