codebee 0.1.19 → 0.1.21
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 +13 -0
- package/README.md +21 -14
- package/app/core/automation.py +6 -6
- package/app/core/capability.py +16 -2
- package/app/core/catalog.py +1 -1
- package/app/core/covergen.py +40 -4
- package/app/core/dispatch.py +141 -0
- package/app/core/flows.py +5 -0
- package/app/core/jobs.py +281 -166
- package/app/core/manager.py +30 -23
- package/app/core/market_remote.py +66 -22
- package/app/core/modelhub.py +145 -11
- package/app/core/pipeline.py +197 -35
- package/app/core/router.py +18 -7
- package/app/core/runner.py +2 -1
- package/app/core/selfupdate.py +24 -14
- package/app/core/settings.py +3 -3
- package/app/core/skills.py +43 -0
- package/app/core/store.py +20 -14
- package/app/core/zentao.py +26 -6
- package/app/main.py +20 -12
- package/app/pet.py +2 -2
- package/app/ui/app.js +87 -63
- package/app/ui/i18n.js +22 -3
- package/app/ui/index.html +11 -11
- package/app/ui/style.css +2 -0
- package/package.json +1 -1
package/app/core/manager.py
CHANGED
|
@@ -31,11 +31,18 @@ _LOCK = threading.RLock()
|
|
|
31
31
|
_STATE = {"detected": {}, "versions": {}, "detect_ts": 0.0, "detect_ev": None}
|
|
32
32
|
|
|
33
33
|
# 能自动写入默认模型的 config.format(其余格式只能手动编辑)
|
|
34
|
-
_WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
|
|
35
|
-
"yaml-line")
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def
|
|
34
|
+
_WRITABLE_FORMATS = ("toml-line", "toml-section", "json", "json-path", "jsonc",
|
|
35
|
+
"yaml-line")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_text(path, preserve_newlines=False):
|
|
39
|
+
"""读取文本并及时关闭句柄;改写配置前可保留原始换行符。"""
|
|
40
|
+
newline = "" if preserve_newlines else None
|
|
41
|
+
with open(path, encoding="utf-8", errors="replace", newline=newline) as fh:
|
|
42
|
+
return fh.read()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _expand(p):
|
|
39
46
|
return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
|
|
40
47
|
|
|
41
48
|
|
|
@@ -475,7 +482,7 @@ def read_model(entry):
|
|
|
475
482
|
if not path or not os.path.isfile(path) or not cfg.get("format"):
|
|
476
483
|
return None
|
|
477
484
|
try:
|
|
478
|
-
text =
|
|
485
|
+
text = _read_text(path)
|
|
479
486
|
except Exception:
|
|
480
487
|
return None
|
|
481
488
|
if cfg["format"] == "toml-line":
|
|
@@ -547,7 +554,7 @@ def write_model(entry, model):
|
|
|
547
554
|
# mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
|
|
548
555
|
# _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
|
|
549
556
|
keys = (cfg.get("model_key") or "model").split(".")
|
|
550
|
-
text =
|
|
557
|
+
text = _read_text(path, preserve_newlines=True)
|
|
551
558
|
new_text, ok = _jsonc_set(text, tuple(keys),
|
|
552
559
|
json.dumps(model, ensure_ascii=False))
|
|
553
560
|
if not ok:
|
|
@@ -555,7 +562,7 @@ def write_model(entry, model):
|
|
|
555
562
|
"error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
|
|
556
563
|
Path(path).write_bytes(new_text.encode("utf-8"))
|
|
557
564
|
elif fmt == "toml-line":
|
|
558
|
-
text =
|
|
565
|
+
text = _read_text(path)
|
|
559
566
|
new_line = 'model = "%s"' % model
|
|
560
567
|
if re.search(r'(?m)^\s*model\s*=\s*"[^"]*"', text):
|
|
561
568
|
text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
|
|
@@ -563,19 +570,19 @@ def write_model(entry, model):
|
|
|
563
570
|
text = text.rstrip("\n") + "\n" + new_line + "\n"
|
|
564
571
|
Path(path).write_bytes(text.encode("utf-8"))
|
|
565
572
|
elif fmt == "toml-section":
|
|
566
|
-
text =
|
|
573
|
+
text = _read_text(path, preserve_newlines=True)
|
|
567
574
|
text = _toml_write_value(text, *_dotted_key(cfg), value=model)
|
|
568
575
|
Path(path).write_bytes(text.encode("utf-8"))
|
|
569
576
|
elif fmt == "yaml-line":
|
|
570
577
|
# newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
|
|
571
578
|
# 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
|
|
572
|
-
text =
|
|
579
|
+
text = _read_text(path, preserve_newlines=True)
|
|
573
580
|
text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
|
|
574
581
|
Path(path).write_bytes(text.encode("utf-8"))
|
|
575
582
|
elif fmt == "json-path":
|
|
576
583
|
# openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
|
|
577
584
|
# 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
|
|
578
|
-
text =
|
|
585
|
+
text = _read_text(path, preserve_newlines=True)
|
|
579
586
|
try:
|
|
580
587
|
data = json.loads(text) if text.strip() else {}
|
|
581
588
|
except Exception:
|
|
@@ -597,7 +604,7 @@ def write_model(entry, model):
|
|
|
597
604
|
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
598
605
|
else:
|
|
599
606
|
try:
|
|
600
|
-
data = json.loads(
|
|
607
|
+
data = json.loads(_read_text(path))
|
|
601
608
|
except FileNotFoundError:
|
|
602
609
|
return {"ok": False,
|
|
603
610
|
"error": "配置文件尚未生成(%s 首次运行后才有),暂无法写入" % path}
|
|
@@ -655,7 +662,7 @@ def _launch_log_path(entry):
|
|
|
655
662
|
def _best_url(log_path, port):
|
|
656
663
|
"""从启动日志提取该端口的信任 URL(含 token 优先)。无日志/未匹配返回 None。"""
|
|
657
664
|
try:
|
|
658
|
-
text =
|
|
665
|
+
text = _read_text(str(log_path))
|
|
659
666
|
except Exception:
|
|
660
667
|
return None
|
|
661
668
|
urls = re.findall(r"https?://[^\s\"'<>]+", text)
|
|
@@ -755,7 +762,7 @@ def _sync_dsh_settings(entry, model, base_url):
|
|
|
755
762
|
return "dsh 配置路径越出用户主目录,已拒绝"
|
|
756
763
|
try:
|
|
757
764
|
if os.path.isfile(path):
|
|
758
|
-
text =
|
|
765
|
+
text = _read_text(path, preserve_newlines=True)
|
|
759
766
|
else:
|
|
760
767
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
761
768
|
text = ""
|
|
@@ -778,7 +785,7 @@ def _dsh_selfcheck_model(entry):
|
|
|
778
785
|
if not path or not os.path.isfile(path):
|
|
779
786
|
return None, ""
|
|
780
787
|
try:
|
|
781
|
-
text =
|
|
788
|
+
text = _read_text(path, preserve_newlines=True)
|
|
782
789
|
except Exception:
|
|
783
790
|
return None, ""
|
|
784
791
|
cur = _yaml_read_value(text, "agent-default-model", "model")
|
|
@@ -802,7 +809,7 @@ def _dsh_key_present():
|
|
|
802
809
|
return True
|
|
803
810
|
try:
|
|
804
811
|
envfile = os.path.join(os.path.expanduser("~"), ".dsh", ".env")
|
|
805
|
-
return "DEEPSEEK_API_KEY" in
|
|
812
|
+
return "DEEPSEEK_API_KEY" in _read_text(envfile)
|
|
806
813
|
except Exception:
|
|
807
814
|
return False
|
|
808
815
|
|
|
@@ -908,7 +915,7 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
908
915
|
("wire_api", q(cp.get("wire_api", "responses")))]
|
|
909
916
|
try:
|
|
910
917
|
if os.path.isfile(path):
|
|
911
|
-
text =
|
|
918
|
+
text = _read_text(path, preserve_newlines=True)
|
|
912
919
|
else:
|
|
913
920
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
914
921
|
text = ""
|
|
@@ -1115,7 +1122,7 @@ def _sync_settings_env(path, updates, remove_keys=()):
|
|
|
1115
1122
|
不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
|
|
1116
1123
|
try:
|
|
1117
1124
|
if os.path.isfile(path):
|
|
1118
|
-
text =
|
|
1125
|
+
text = _read_text(path)
|
|
1119
1126
|
try:
|
|
1120
1127
|
data = json.loads(text)
|
|
1121
1128
|
except Exception:
|
|
@@ -1235,7 +1242,7 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1235
1242
|
errs.append("路径越出主目录:%s" % path)
|
|
1236
1243
|
continue
|
|
1237
1244
|
if os.path.isfile(path):
|
|
1238
|
-
text =
|
|
1245
|
+
text = _read_text(path, preserve_newlines=True)
|
|
1239
1246
|
else:
|
|
1240
1247
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1241
1248
|
text = ""
|
|
@@ -1326,7 +1333,7 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1326
1333
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1327
1334
|
text = ""
|
|
1328
1335
|
if os.path.isfile(path):
|
|
1329
|
-
text =
|
|
1336
|
+
text = _read_text(path, preserve_newlines=True)
|
|
1330
1337
|
shutil.copyfile(path, path + ".bak")
|
|
1331
1338
|
# 移除旧托管块与旧顶层键(防重复/防键落进别的表)
|
|
1332
1339
|
text = _re.sub(r"# >>> CodeBee managed.*?# <<< CodeBee managed <<<\n?",
|
|
@@ -1403,7 +1410,7 @@ def _sync_agent_injection(entry, binding):
|
|
|
1403
1410
|
if entry["id"] in _NO_CHANNEL_HINT:
|
|
1404
1411
|
if binding.get("env"):
|
|
1405
1412
|
return "已按绑定注入 env,但该 CLI 未必认 CodeBee 的凭据通道,打开后若要求登录请在其界面内登录"
|
|
1406
|
-
return "
|
|
1413
|
+
return "未指定可用供应商:打开后使用 CLI 自带登录;需要固定注入请到「模型调度(可选)」页指定"
|
|
1407
1414
|
return None
|
|
1408
1415
|
|
|
1409
1416
|
|
|
@@ -1523,7 +1530,7 @@ def launch(entry, open_browser=True):
|
|
|
1523
1530
|
notes = _sync_launch_model(entry, binding)
|
|
1524
1531
|
if is_dsh and not (binding.get("env") or {}) and not _dsh_key_present():
|
|
1525
1532
|
notes.append("未发现 dsh 密钥:打开后可能需在 dsh 内登录配置;"
|
|
1526
|
-
"
|
|
1533
|
+
"要打开即用,请到「模型调度(可选)」页给 DeepSeek Harness 指定 openai 协议供应商")
|
|
1527
1534
|
name = entry.get("name", entry["id"])
|
|
1528
1535
|
kind = (launch.get("kind") or "console").lower()
|
|
1529
1536
|
extra = (";".join(notes)) if notes else ""
|
|
@@ -1630,7 +1637,7 @@ def _open_when_ready(port, url, log_path, timeout=30):
|
|
|
1630
1637
|
# ---------------------------------------------------------------- 安装/升级
|
|
1631
1638
|
|
|
1632
1639
|
def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
1633
|
-
"""执行 install/upgrade/uninstall
|
|
1640
|
+
"""执行 install/upgrade/uninstall 命令(由任务执行器异步运行,日志实时落盘)。"""
|
|
1634
1641
|
if op == "uninstall":
|
|
1635
1642
|
cmd = catalog.uninstall_command(entry)
|
|
1636
1643
|
if not cmd:
|
|
@@ -30,9 +30,10 @@ import subprocess
|
|
|
30
30
|
import tarfile
|
|
31
31
|
import tempfile
|
|
32
32
|
import time
|
|
33
|
-
import urllib.error
|
|
34
|
-
import urllib.request
|
|
35
|
-
import
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
import urllib.parse
|
|
36
|
+
import zipfile
|
|
36
37
|
from pathlib import Path
|
|
37
38
|
from urllib.parse import urlparse
|
|
38
39
|
|
|
@@ -93,7 +94,8 @@ _CAP_DOWNLOAD = 80 * 1024 * 1024
|
|
|
93
94
|
_CAP_UNPACKED = 120 * 1024 * 1024
|
|
94
95
|
_CAP_FILES = 500
|
|
95
96
|
_CAP_FILE_TEXT = 512 * 1024
|
|
96
|
-
_CAP_TOTAL_TEXT = 4 * 1024 * 1024
|
|
97
|
+
_CAP_TOTAL_TEXT = 4 * 1024 * 1024
|
|
98
|
+
_MAX_REDIRECTS = 3
|
|
97
99
|
|
|
98
100
|
|
|
99
101
|
def _cache_dir():
|
|
@@ -130,18 +132,52 @@ def assert_public_url(url):
|
|
|
130
132
|
return host, port
|
|
131
133
|
|
|
132
134
|
|
|
133
|
-
def _fetch(url, cap=_CAP_MANIFEST):
|
|
135
|
+
def _fetch(url, cap=_CAP_MANIFEST):
|
|
134
136
|
"""带体量上限的 https GET;返回 bytes。
|
|
135
137
|
|
|
136
138
|
两段式网络策略:先走默认通道(Windows 上 urllib 会读注册表系统代理,
|
|
137
139
|
依赖代理上网的环境靠它),失败再自动直连重试一次(实测本机代理对部分
|
|
138
140
|
CDN 文件返回 404/篡改,直连正常;反过来需要代理的网络第一段就成功)。
|
|
139
141
|
体量超限属确定性错误,不重试。"""
|
|
140
|
-
assert_public_url(url)
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
assert_public_url(url)
|
|
143
|
+
|
|
144
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
145
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
def _close_quietly(resp):
|
|
149
|
+
try:
|
|
150
|
+
resp.close()
|
|
151
|
+
except Exception:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
def _open(opener, target):
|
|
155
|
+
"""逐跳 GET;重定向目标在发起下一跳前重新过 SSRF 网关。"""
|
|
156
|
+
current = target
|
|
157
|
+
for hop in range(_MAX_REDIRECTS + 1):
|
|
158
|
+
assert_public_url(current)
|
|
159
|
+
req = urllib.request.Request(current, headers={
|
|
160
|
+
"User-Agent": "CodeBee-Market/1.0",
|
|
161
|
+
"Accept": "*/*",
|
|
162
|
+
})
|
|
163
|
+
try:
|
|
164
|
+
resp = opener.open(req, timeout=30)
|
|
165
|
+
code = getattr(resp, "status", getattr(resp, "code", 200))
|
|
166
|
+
except urllib.error.HTTPError as e:
|
|
167
|
+
code, resp = e.code, e
|
|
168
|
+
if code not in (301, 302, 303, 307, 308):
|
|
169
|
+
if isinstance(resp, urllib.error.HTTPError):
|
|
170
|
+
raise resp
|
|
171
|
+
return resp
|
|
172
|
+
location = resp.headers.get("Location")
|
|
173
|
+
if not location:
|
|
174
|
+
_close_quietly(resp)
|
|
175
|
+
raise ValueError("重定向缺少 Location: %s" % current)
|
|
176
|
+
_close_quietly(resp)
|
|
177
|
+
current = urllib.parse.urljoin(current, location)
|
|
178
|
+
if hop >= _MAX_REDIRECTS:
|
|
179
|
+
raise ValueError("重定向次数超过上限: %s" % target)
|
|
180
|
+
raise ValueError("重定向失败: %s" % target)
|
|
145
181
|
|
|
146
182
|
def _read(open_fn):
|
|
147
183
|
with open_fn() as resp:
|
|
@@ -156,15 +192,17 @@ def _fetch(url, cap=_CAP_MANIFEST):
|
|
|
156
192
|
chunks.append(chunk)
|
|
157
193
|
return b"".join(chunks)
|
|
158
194
|
|
|
159
|
-
try:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
195
|
+
try:
|
|
196
|
+
# 默认 opener 仍读取系统代理,但显式禁用自动重定向。
|
|
197
|
+
opener = urllib.request.build_opener(
|
|
198
|
+
_NoRedirect(), urllib.request.HTTPSHandler(context=tlsctx.context()))
|
|
199
|
+
return _read(lambda: _open(opener, url))
|
|
200
|
+
except (urllib.error.HTTPError, urllib.error.URLError):
|
|
201
|
+
# 直连重试:ProxyHandler({}) 显式清空代理;每一跳仍经过 SSRF 校验。
|
|
202
|
+
opener = urllib.request.build_opener(
|
|
203
|
+
_NoRedirect(), urllib.request.ProxyHandler({}),
|
|
204
|
+
urllib.request.HTTPSHandler(context=tlsctx.context()))
|
|
205
|
+
return _read(lambda: _open(opener, url))
|
|
168
206
|
|
|
169
207
|
|
|
170
208
|
# ---------------------------------------------------------------- 清单解析
|
|
@@ -772,16 +810,22 @@ def _gh_fetch_files(entry, tmp):
|
|
|
772
810
|
raise ValueError("jsdelivr 拉取失败: %s/%s(%s)" % (owner, repo, last_err))
|
|
773
811
|
|
|
774
812
|
|
|
775
|
-
def _download_git(entry, tmp):
|
|
813
|
+
def _download_git(entry, tmp):
|
|
776
814
|
"""git 浅克隆取子目录(Anthropic 生态的 git-subdir 来源)。"""
|
|
777
815
|
if shutil.which("git") is None:
|
|
778
816
|
raise ValueError("本机没有 git,无法安装 git-subdir 来源的插件")
|
|
779
|
-
inst = entry["install"]
|
|
817
|
+
inst = entry["install"]
|
|
818
|
+
# 非 GitHub 来源没有 codeload/jsdelivr 的安全收口,必须在 clone 前
|
|
819
|
+
# 复用同一公网 HTTPS 网关,避免 git 自己跟随重定向或访问内网。
|
|
820
|
+
assert_public_url(inst.get("url") or "")
|
|
780
821
|
dst = tmp / "git"
|
|
781
822
|
sub = str(inst.get("path") or "").replace("\\", "/")
|
|
782
823
|
if sub.startswith("/") or ":" in sub or ".." in _rel_parts(sub):
|
|
783
824
|
raise ValueError("插件子目录路径可疑: %s" % inst.get("path"))
|
|
784
|
-
|
|
825
|
+
# git 会自行跟随 HTTP 重定向,而重定向目标无法再经过 assert_public_url;
|
|
826
|
+
# ��装来源宁可明确失败,也不能让 clone 跳进内网。
|
|
827
|
+
cmd = ["git", "-c", "http.followRedirects=false", "clone",
|
|
828
|
+
"--depth", "1", "--single-branch", "--quiet"]
|
|
785
829
|
if inst.get("ref"):
|
|
786
830
|
cmd += ["--branch", inst["ref"]]
|
|
787
831
|
cmd += [inst["url"], str(dst)]
|
package/app/core/modelhub.py
CHANGED
|
@@ -1066,6 +1066,20 @@ def _binding_chain(b):
|
|
|
1066
1066
|
return [{"provider_id": pid, "model": n} for n in names]
|
|
1067
1067
|
|
|
1068
1068
|
|
|
1069
|
+
def _binding_for(agent_kind_or_id):
|
|
1070
|
+
"""取 CLI 绑定,仅允许两组历史别名互通。
|
|
1071
|
+
|
|
1072
|
+
旧写法把所有非 codex 标识都回落到 claude-code,导致 opencode/qwen 等
|
|
1073
|
+
未配置时误继承 Claude 的显式绑定并触发死链闸门。
|
|
1074
|
+
"""
|
|
1075
|
+
key = str(agent_kind_or_id or "")
|
|
1076
|
+
all_bindings = bindings()
|
|
1077
|
+
if key in all_bindings:
|
|
1078
|
+
return all_bindings[key] or {}
|
|
1079
|
+
alias = {"codex": "codex-cli", "claude": "claude-code"}.get(key)
|
|
1080
|
+
return (all_bindings.get(alias) or {}) if alias else {}
|
|
1081
|
+
|
|
1082
|
+
|
|
1069
1083
|
def set_binding(agent_id, provider_id=None, model=None, models=None,
|
|
1070
1084
|
difficulty_routing=None, chain=None):
|
|
1071
1085
|
"""写一条 CLI 绑定。chain=[{provider_id, model}] 是跨厂商模型链(唯一真源),
|
|
@@ -1973,7 +1987,7 @@ def _model_image_in(prov, model):
|
|
|
1973
1987
|
return False
|
|
1974
1988
|
|
|
1975
1989
|
|
|
1976
|
-
def bind_agent(agent, difficulty="default"):
|
|
1990
|
+
def bind_agent(agent, difficulty="default", task_type="", role=""):
|
|
1977
1991
|
"""按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。
|
|
1978
1992
|
|
|
1979
1993
|
binding_configured:该 CLI 是否配过绑定链(配没配与解析结果分开带出——
|
|
@@ -1987,13 +2001,21 @@ def bind_agent(agent, difficulty="default"):
|
|
|
1987
2001
|
manager.sync_runtime_config(agent)
|
|
1988
2002
|
except Exception:
|
|
1989
2003
|
pass
|
|
2004
|
+
task_type = task_type or agent.get("_dispatch_task_type") or ""
|
|
2005
|
+
role = role or agent.get("_dispatch_role") or ""
|
|
1990
2006
|
rid = agent.get("id")
|
|
1991
|
-
b =
|
|
1992
|
-
|
|
1993
|
-
|
|
2007
|
+
b = _binding_for(rid)
|
|
2008
|
+
# provider_id 即使没有显式模型链也代表用户锁定了供应商;空 bindings
|
|
2009
|
+
# 才是“完全交给系统推荐”的默认模式。
|
|
2010
|
+
configured = bool(_binding_chain(b) or b.get("provider_id"))
|
|
1994
2011
|
r = resolve_binding(rid, difficulty) or resolve_binding(agent.get("kind"), difficulty)
|
|
2012
|
+
binding_mode = "explicit" if configured else "auto"
|
|
2013
|
+
if not r and not configured:
|
|
2014
|
+
r = recommend_binding(rid, difficulty, task_type=task_type, role=role)
|
|
2015
|
+
binding_mode = "auto" if r else "cli_default"
|
|
1995
2016
|
a = dict(agent)
|
|
1996
2017
|
a["binding_configured"] = configured
|
|
2018
|
+
a["binding_mode"] = binding_mode
|
|
1997
2019
|
if not r:
|
|
1998
2020
|
return a
|
|
1999
2021
|
merged = dict(agent.get("env") or {})
|
|
@@ -2007,6 +2029,26 @@ def bind_agent(agent, difficulty="default"):
|
|
|
2007
2029
|
a["codex_provider"] = r["codex_provider"]
|
|
2008
2030
|
if r.get("call_chain"):
|
|
2009
2031
|
a["call_chain"] = r["call_chain"]
|
|
2032
|
+
# 统一级联入口:代码、写作、调研等所有流程都复用同一套模型链排序。
|
|
2033
|
+
# 默认关闭,保留用户手工链顺序;开启后仅 easy 任务按成本/档位优先。
|
|
2034
|
+
routing_enabled = bool((b or {}).get("difficulty_routing"))
|
|
2035
|
+
if difficulty in ("easy", "hard"):
|
|
2036
|
+
try:
|
|
2037
|
+
from .settings_schema import get as ss_get, register_default_namespaces
|
|
2038
|
+
register_default_namespaces()
|
|
2039
|
+
cascade_enabled = bool(ss_get("cascade", "enabled"))
|
|
2040
|
+
if (routing_enabled or cascade_enabled) and a.get("call_chain"):
|
|
2041
|
+
from . import capability
|
|
2042
|
+
data = _load()
|
|
2043
|
+
from . import dispatch
|
|
2044
|
+
entries, decisions = dispatch.rank_model_entries(
|
|
2045
|
+
a.get("call_chain") or [],
|
|
2046
|
+
{p.get("id"): p for p in data.get("providers") or []},
|
|
2047
|
+
data.get("pricing") or {}, difficulty, task_type, role)
|
|
2048
|
+
a["call_chain"] = entries
|
|
2049
|
+
a["dispatch_decisions"] = decisions
|
|
2050
|
+
except Exception:
|
|
2051
|
+
pass
|
|
2010
2052
|
return a
|
|
2011
2053
|
|
|
2012
2054
|
|
|
@@ -2131,7 +2173,7 @@ def binding_dead_msg(cli_id):
|
|
|
2131
2173
|
protos = ()
|
|
2132
2174
|
hint = ("该 CLI 仅接受 %s 协议的已启用供应商;" % "、".join(protos)) if protos else ""
|
|
2133
2175
|
return ("绑定链全部失效(链上供应商已停用/删除/无密钥,或模型已停用),"
|
|
2134
|
-
"本步判失败、不回落 CLI 本机默认——%s
|
|
2176
|
+
"本步判失败、不回落 CLI 本机默认——%s请在「模型调度(可选)」页为该 CLI 指定已启用的供应商" % hint)
|
|
2135
2177
|
|
|
2136
2178
|
|
|
2137
2179
|
def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
@@ -2142,8 +2184,7 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2142
2184
|
→ 整体回落 CLI 默认(None)。纯链条目(无供应商)不注入 env,只传 -m。
|
|
2143
2185
|
难度路由只在无显式链时生效。
|
|
2144
2186
|
"""
|
|
2145
|
-
b =
|
|
2146
|
-
"codex-cli" if agent_kind_or_id == "codex" else "claude-code") or {}
|
|
2187
|
+
b = _binding_for(agent_kind_or_id)
|
|
2147
2188
|
chain = _binding_chain(b)
|
|
2148
2189
|
provs = {p.get("id"): p for p in providers()}
|
|
2149
2190
|
routing = bool(b.get("difficulty_routing"))
|
|
@@ -2198,6 +2239,16 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2198
2239
|
break
|
|
2199
2240
|
if not entries:
|
|
2200
2241
|
return None
|
|
2242
|
+
# 显式难度路由开启时,解析层就是唯一排序真源;关闭时保持手工链顺序。
|
|
2243
|
+
if routing and tier in ("easy", "hard"):
|
|
2244
|
+
try:
|
|
2245
|
+
from . import dispatch
|
|
2246
|
+
data = _load()
|
|
2247
|
+
entries, _decisions = dispatch.rank_model_entries(
|
|
2248
|
+
entries, {p.get("id"): p for p in data.get("providers") or []},
|
|
2249
|
+
data.get("pricing") or {}, tier, "", "")
|
|
2250
|
+
except Exception:
|
|
2251
|
+
pass
|
|
2201
2252
|
head = entries[0]
|
|
2202
2253
|
out = {"model": head["model"], "env": head["env"], "provider": head.get("provider"),
|
|
2203
2254
|
# model_fallbacks 是「换模型」的列表(runner 无链时的回退用):
|
|
@@ -2253,6 +2304,89 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2253
2304
|
return out
|
|
2254
2305
|
|
|
2255
2306
|
|
|
2307
|
+
def recommend_binding(agent_kind_or_id, difficulty="default", task_type="", role=""):
|
|
2308
|
+
"""按当前可用供应商临时生成推荐链,不写入用户 bindings。
|
|
2309
|
+
|
|
2310
|
+
这是默认调度路径:只有没有显式 provider_id/chain 时才由 bind_agent 调用。
|
|
2311
|
+
推荐结果仍经过 CLI 协议、供应商启停、密钥、健康状态和模型启停过滤,
|
|
2312
|
+
再按任务类型、难度、档位和价格排序。没有可用供应商时返回 None,调用方
|
|
2313
|
+
继续使用 CLI 自带登录态与默认模型。
|
|
2314
|
+
"""
|
|
2315
|
+
allowed = bindable_protocols(agent_kind_or_id)
|
|
2316
|
+
try:
|
|
2317
|
+
from . import health
|
|
2318
|
+
down_set = health.down_names()
|
|
2319
|
+
except Exception:
|
|
2320
|
+
down_set = set()
|
|
2321
|
+
data = _load()
|
|
2322
|
+
provs = {p.get("id"): p for p in data.get("providers") or []}
|
|
2323
|
+
candidates = []
|
|
2324
|
+
for prov in data.get("providers") or []:
|
|
2325
|
+
pid = (prov.get("id") or "").strip()
|
|
2326
|
+
if not pid or not prov.get("enabled", True) or not prov.get("api_key"):
|
|
2327
|
+
continue
|
|
2328
|
+
if prov.get("name") in down_set:
|
|
2329
|
+
continue
|
|
2330
|
+
ep = _entry_endpoint(prov, allowed)
|
|
2331
|
+
if not ep:
|
|
2332
|
+
continue
|
|
2333
|
+
if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
|
|
2334
|
+
continue
|
|
2335
|
+
names = [m.get("name") for m in _enabled_models(prov)
|
|
2336
|
+
if isinstance(m, dict) and m.get("name")]
|
|
2337
|
+
if not names and prov.get("model"):
|
|
2338
|
+
names = [str(prov.get("model"))]
|
|
2339
|
+
# 候选阶段不能先截前三个:便宜档往往排在供应商列表后面,easy 任务
|
|
2340
|
+
# 需要看到完整启用列表后再按成本与档位评分。最终调用链仍受上限约束。
|
|
2341
|
+
for model in names:
|
|
2342
|
+
candidates.append({"provider_id": pid, "model": model})
|
|
2343
|
+
if not candidates:
|
|
2344
|
+
return None
|
|
2345
|
+
from . import dispatch
|
|
2346
|
+
ranked, decisions = dispatch.rank_model_entries(
|
|
2347
|
+
candidates, provs, data.get("pricing") or {}, difficulty,
|
|
2348
|
+
task_type=task_type, role=role, force=True)
|
|
2349
|
+
entries, entry_decisions = [], []
|
|
2350
|
+
decision_by_model = {
|
|
2351
|
+
(d.get("provider_id") or "", d.get("model") or ""): d
|
|
2352
|
+
for d in decisions}
|
|
2353
|
+
for item in ranked[:MAX_CHAIN_ATTEMPTS]:
|
|
2354
|
+
prov = provs.get(item.get("provider_id"))
|
|
2355
|
+
if not prov:
|
|
2356
|
+
continue
|
|
2357
|
+
ep = _entry_endpoint(prov, allowed)
|
|
2358
|
+
if not ep:
|
|
2359
|
+
continue
|
|
2360
|
+
pid = item.get("provider_id") or ""
|
|
2361
|
+
# 与显式绑定完全同口径:同一模型按可用 KEY 展开,runner 才能在首 KEY
|
|
2362
|
+
# 欠费/失效时记账冷却并尝试下一把。最终尝试数仍受硬上限约束。
|
|
2363
|
+
for kk in _chain_keys(prov):
|
|
2364
|
+
entries.append(_chain_entry_env(
|
|
2365
|
+
prov, item.get("model") or "", target=agent_kind_or_id,
|
|
2366
|
+
endpoint=ep, key=kk["key"], key_id=kk.get("id") or "",
|
|
2367
|
+
provider_id=pid))
|
|
2368
|
+
entry_decisions.append(decision_by_model.get(
|
|
2369
|
+
(pid, item.get("model") or ""), {}))
|
|
2370
|
+
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
2371
|
+
break
|
|
2372
|
+
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
2373
|
+
break
|
|
2374
|
+
if not entries:
|
|
2375
|
+
return None
|
|
2376
|
+
head = entries[0]
|
|
2377
|
+
out = {"model": head["model"], "env": head["env"],
|
|
2378
|
+
"provider": head.get("provider"),
|
|
2379
|
+
"model_fallbacks": list(dict.fromkeys(
|
|
2380
|
+
e["model"] for e in entries[1:]
|
|
2381
|
+
if e.get("model") and e["model"] != head["model"])),
|
|
2382
|
+
"call_chain": [dict(e) for e in entries],
|
|
2383
|
+
"dispatch_decisions": entry_decisions,
|
|
2384
|
+
"binding_mode": "auto"}
|
|
2385
|
+
if head.get("codex_provider"):
|
|
2386
|
+
out["codex_provider"] = head["codex_provider"]
|
|
2387
|
+
return out
|
|
2388
|
+
|
|
2389
|
+
|
|
2256
2390
|
def launch_pick(agent_id, protocols):
|
|
2257
2391
|
"""「一键打开」专属选链:绑定链里第一个「已启用+有密钥+协议匹配」的供应商。
|
|
2258
2392
|
|
|
@@ -2294,13 +2428,13 @@ def launch_pick(agent_id, protocols):
|
|
|
2294
2428
|
mismatch = True
|
|
2295
2429
|
if disabled:
|
|
2296
2430
|
return None, ("绑定链里的 %s 已停用或无密钥:打开后需在其自带界面登录;"
|
|
2297
|
-
"
|
|
2431
|
+
"要打开即用请在「模型调度(可选)」页启用"
|
|
2298
2432
|
% "、".join(dict.fromkeys(disabled)))
|
|
2299
2433
|
if mismatch:
|
|
2300
2434
|
return None, ("当前绑定的供应商协议与该 CLI 不匹配:打开后需在其自带界面登录;"
|
|
2301
|
-
"
|
|
2302
|
-
return None, ("
|
|
2303
|
-
"
|
|
2435
|
+
"要打开即用请在「模型调度(可选)」页改为可注入协议的供应商")
|
|
2436
|
+
return None, ("未指定供应商:打开后使用该 CLI 自带的登录与配置;"
|
|
2437
|
+
"需要固定注入时请到「模型调度(可选)」页指定")
|
|
2304
2438
|
|
|
2305
2439
|
|
|
2306
2440
|
def migrate_orch_models():
|