codebee 0.1.20 → 0.1.22

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.
@@ -2,9 +2,9 @@
2
2
  """知识库(Knowledge):运行产出自动整理形成的可复用知识 + 个人知识管理入口。
3
3
 
4
4
  与经验库(skills.py)的分工边界:
5
- - 教训(lessons)记「别这么做」——负面规则,来源=评审暴露的问题(verdict/issues);
6
- - 知识(knowledge)记「已知是这样」——领域事实/平台规则/结论/方法论,
7
- 来源=run 产出材料(调研报告/文档)。两者输入源不同,提炼互不双写。
5
+ - 经验(lessons)记「下次怎么做」——流程规范、实践方法和评审暴露的教训;
6
+ - 知识(knowledge)记「已知是什么」——领域事实、外部规则和可验证结论。
7
+ 运行产出中的实践方法会分流进经验库,事实才进入知识库,避免同一方法双写。
8
8
 
9
9
  质量闸门:条目默认直接转正(approved)参与注入——用户拍板:人工把关太重,
10
10
  提炼提示词里的「只保留有明确复用价值的」约束兜质量。status 字段保留 draft
@@ -243,10 +243,13 @@ def _bump_hits(ids):
243
243
  # ---------------------------------------------------------------- 自动整理
244
244
 
245
245
  KNOWLEDGE_PROMPT = """你是编排系统的知识管理员。下面是一次任务的目标与它的产出材料(调研报告/文档等)。
246
- 请从产出中提炼**可长期复用的知识条目**:领域事实、平台规则、结论、方法论。
247
- 注意:只提炼事实性/结论性内容;「下次要避免什么」这类负面教训由另一个复盘流程负责,你不要写。
246
+ 请从产出中提炼可长期复用的内容,并明确分成两类:
247
+ - fact:回答「是什么」,只含领域事实、外部规则和可验证结论,进入知识库;
248
+ - practice:回答「怎么做」,含流程、操作方法和正向实践,进入经验库;此类必须填写 category。
249
+ 「下次要避免什么」这类负面教训由评审复盘流程负责,不要重复提炼。
248
250
  只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
249
- {"entries": [{"title": "≤20 字的知识标题", "body": "具体结论(≤200 字,自含上下文,脱离本次任务也能看懂)", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(事实采集日)"}]}
251
+ {"entries": [{"kind": "fact 或 practice", "title": "≤20 字的标题", "body": "具体内容(≤200 字,自含上下文,脱离本次任务也能看懂)", "category": "practice 的经验分类", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(fact 的事实采集日)"}]}
252
+ category 只能从以下枚举选择:__CATEGORIES__。
250
253
  最多 3 条,只保留有明确复用价值的;产出里没有值得沉淀的就返回空数组。
251
254
 
252
255
  ## 任务类型
@@ -298,7 +301,7 @@ def _pick_material(run):
298
301
 
299
302
 
300
303
  def learn_from_run(run_id):
301
- """运行结束后由编排者从产出材料提炼知识条目(草稿态)。返回写入条数。
304
+ """运行结束后从产出提炼事实与实践并分流入库。返回总写入条数。
302
305
 
303
306
  知识没有便宜的兜底路径:无编排者/无产出/非真实运行(mock)一律静默跳过,
304
307
  宁缺毋滥——教训库的规则兜底搬到这里只会制造垃圾知识。只有正常跑完(done)
@@ -326,6 +329,7 @@ def learn_from_run(run_id):
326
329
  return 0
327
330
  prov, model = orch
328
331
  prompt = (KNOWLEDGE_PROMPT
332
+ .replace("__CATEGORIES__", "、".join(skills.LESSON_CATEGORIES))
329
333
  .replace("__TYPE__", str(task.get("type")))
330
334
  .replace("__GOAL__", (task.get("goal") or "")[:600])
331
335
  .replace("__MATERIAL__", material))
@@ -339,6 +343,14 @@ def learn_from_run(run_id):
339
343
  for x in raw[:3]:
340
344
  if not (isinstance(x, dict) and x.get("title") and x.get("body")):
341
345
  continue
346
+ kind = str(x.get("kind") or "fact").strip().lower()
347
+ if kind == "practice":
348
+ if skills.upsert_lesson(task.get("type") or "*", x["title"],
349
+ x["body"], source=run_id,
350
+ category=x.get("category"),
351
+ dim="%s %s" % (x["title"], x["body"])):
352
+ n += 1
353
+ continue
342
354
  as_of = str(x.get("as_of") or "").strip()
343
355
  if not re.match(r"^\d{4}-\d{2}-\d{2}$", as_of):
344
356
  as_of = _now()[:10]
@@ -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 _expand(p):
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 = open(path, encoding="utf-8", errors="replace").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 = open(path, encoding="utf-8", errors="replace").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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(open(path, encoding="utf-8", errors="replace").read())
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 = open(str(log_path), encoding="utf-8", errors="replace").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 open(envfile, encoding="utf-8", errors="replace").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 = open(path, encoding="utf-8", errors="replace").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 = open(path, encoding="utf-8", errors="replace", newline="").read()
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 "未绑定可用供应商:打开后需在其自带界面登录;要打开即用请到「CLI 绑定」页绑定"
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
- "要打开即用,请到「CLI 绑定」页给 DeepSeek Harness 绑定 openai 协议供应商")
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 zipfile
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
- req = urllib.request.Request(url, headers={
142
- "User-Agent": "CodeBee-Market/1.0",
143
- "Accept": "*/*",
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
- return _read(lambda: urllib.request.urlopen(
161
- req, timeout=30, context=tlsctx.context())) # 默认 opener:含系统代理
162
- except (urllib.error.HTTPError, urllib.error.URLError):
163
- # 直连重试:ProxyHandler({}) 显式清空代理
164
- opener = urllib.request.build_opener(
165
- urllib.request.ProxyHandler({}),
166
- urllib.request.HTTPSHandler(context=tlsctx.context()))
167
- return _read(lambda: opener.open(req, timeout=30))
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
- cmd = ["git", "clone", "--depth", "1", "--single-branch", "--quiet"]
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)]
@@ -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 = bindings().get(rid) or bindings().get(
1992
- "codex-cli" if rid == "codex" else "claude-code") or {}
1993
- configured = bool(_binding_chain(b))
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请在「CLI 绑定」页为该 CLI 绑定已启用的供应商" % hint)
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 = bindings().get(agent_kind_or_id) or bindings().get(
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
- "要打开即用请在「CLI 绑定」页启用"
2431
+ "要打开即用请在「模型调度(可选)」页启用"
2298
2432
  % "、".join(dict.fromkeys(disabled)))
2299
2433
  if mismatch:
2300
2434
  return None, ("当前绑定的供应商协议与该 CLI 不匹配:打开后需在其自带界面登录;"
2301
- "要打开即用请在「CLI 绑定」页换绑可注入协议的供应商")
2302
- return None, ("未绑定供应商:打开后需在其自带界面登录;"
2303
- "要打开即用请到「CLI 绑定」页绑定")
2435
+ "要打开即用请在「模型调度(可选)」页改为可注入协议的供应商")
2436
+ return None, ("未指定供应商:打开后使用该 CLI 自带的登录与配置;"
2437
+ "需要固定注入时请到「模型调度(可选)」页指定")
2304
2438
 
2305
2439
 
2306
2440
  def migrate_orch_models():