codebee 0.1.65 → 0.1.67

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.
Files changed (116) hide show
  1. package/CHANGELOG.md +532 -513
  2. package/LICENSE +21 -21
  3. package/README.md +494 -487
  4. package/app/core/acceptance.py +100 -0
  5. package/app/core/aiflavor.py +197 -197
  6. package/app/core/artifacts.py +148 -148
  7. package/app/core/assets.py +110 -0
  8. package/app/core/attachments.py +623 -532
  9. package/app/core/automation.py +758 -758
  10. package/app/core/backup.py +470 -470
  11. package/app/core/beekeeper.py +183 -183
  12. package/app/core/board.py +209 -209
  13. package/app/core/bookmeta.py +930 -759
  14. package/app/core/bookmeta_catalog.py +180 -180
  15. package/app/core/branching.py +137 -137
  16. package/app/core/builtin_agent.py +1234 -1191
  17. package/app/core/capability.py +144 -144
  18. package/app/core/catalog.py +23 -24
  19. package/app/core/chaptersafe.py +32 -32
  20. package/app/core/cleanup.py +303 -303
  21. package/app/core/compaction.py +201 -186
  22. package/app/core/covergen.py +356 -356
  23. package/app/core/defectretro.py +43 -0
  24. package/app/core/diagnostics.py +114 -114
  25. package/app/core/dispatch.py +173 -173
  26. package/app/core/dispatch_log.py +113 -113
  27. package/app/core/env_scrub.py +85 -85
  28. package/app/core/error_codes.py +220 -65
  29. package/app/core/errorlog.py +137 -137
  30. package/app/core/evaluation.py +174 -0
  31. package/app/core/failure_notify.py +290 -0
  32. package/app/core/flows.py +401 -355
  33. package/app/core/goal_service.py +167 -167
  34. package/app/core/health.py +374 -342
  35. package/app/core/history.py +38 -38
  36. package/app/core/hooks.py +162 -156
  37. package/app/core/jobs.py +1050 -1022
  38. package/app/core/knowledge.py +56 -5
  39. package/app/core/manager.py +2122 -1996
  40. package/app/core/market.py +362 -362
  41. package/app/core/market_remote.py +190 -92
  42. package/app/core/mocks.py +64 -64
  43. package/app/core/modelhub.py +3546 -3332
  44. package/app/core/notify.py +106 -106
  45. package/app/core/operations.py +228 -0
  46. package/app/core/paihang.py +138 -109
  47. package/app/core/paths.py +2 -2
  48. package/app/core/pipeline.py +4808 -4494
  49. package/app/core/planner.py +37 -32
  50. package/app/core/portguard.py +111 -111
  51. package/app/core/preview.py +293 -293
  52. package/app/core/publish/__init__.py +7 -7
  53. package/app/core/publish/auto.py +367 -367
  54. package/app/core/publish/browser.py +539 -533
  55. package/app/core/publish/fanqie.py +159 -128
  56. package/app/core/publish/flow.py +440 -406
  57. package/app/core/publish/flows-fanqie-calibrated.json +17 -9
  58. package/app/core/publish/flows-qimao-calibrated.json +6 -2
  59. package/app/core/publish/ledger.py +203 -198
  60. package/app/core/publish/manager.py +692 -590
  61. package/app/core/publish/qimao.py +141 -141
  62. package/app/core/publish/ws.py +139 -139
  63. package/app/core/redact.py +38 -38
  64. package/app/core/repeat_guard.py +129 -124
  65. package/app/core/revisions.py +51 -0
  66. package/app/core/router.py +119 -81
  67. package/app/core/runner.py +2065 -2042
  68. package/app/core/selfupdate.py +384 -384
  69. package/app/core/session_log.py +161 -161
  70. package/app/core/sessions.py +312 -312
  71. package/app/core/settings.py +149 -149
  72. package/app/core/settings_schema.py +262 -262
  73. package/app/core/share_page.py +114 -114
  74. package/app/core/skill_scan.py +114 -106
  75. package/app/core/skillpacks/fanqie-novel.md +80 -80
  76. package/app/core/skillpacks/market/character-bible.md +66 -66
  77. package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
  78. package/app/core/skillpacks/market/git-workflow.md +57 -57
  79. package/app/core/skillpacks/market/release-notes.md +72 -72
  80. package/app/core/skillpacks/market/weekly-report.md +71 -71
  81. package/app/core/skillpacks/market/worldview-consistency.md +70 -70
  82. package/app/core/skills.py +315 -73
  83. package/app/core/step_runner.py +132 -132
  84. package/app/core/store.py +230 -11
  85. package/app/core/task_compile.py +199 -199
  86. package/app/core/telemetry.py +292 -292
  87. package/app/core/tlsctx.py +64 -64
  88. package/app/core/token_meter.py +147 -147
  89. package/app/core/upstream.py +41 -0
  90. package/app/core/usage.py +114 -3
  91. package/app/core/volumes.py +389 -355
  92. package/app/core/wxdigest.py +858 -858
  93. package/app/core/zentao.py +2465 -2447
  94. package/app/main.py +2957 -2832
  95. package/app/pick_dialog.py +110 -110
  96. package/app/ui/app.js +14384 -13977
  97. package/app/ui/artdiff.js +74 -0
  98. package/app/ui/board.css +20 -41
  99. package/app/ui/board.html +162 -162
  100. package/app/ui/board.js +305 -305
  101. package/app/ui/i18n.js +2766 -2711
  102. package/app/ui/icons/bee.svg +79 -79
  103. package/app/ui/index.html +7 -1
  104. package/app/ui/manifest.json +16 -16
  105. package/app/ui/qrcode.js +2297 -2297
  106. package/app/ui/style.css +6179 -6125
  107. package/bin/tutti.js +147 -147
  108. package/package.json +46 -41
  109. package/tools/wx_reader.py +261 -261
  110. package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +0 -88
  111. package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +0 -126
  112. package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
  113. package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +0 -210
  114. package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
  115. package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
  116. package/app/.playwright-cli/run-detail-after.png +0 -0
@@ -1,124 +1,129 @@
1
- # -*- coding: utf-8 -*-
2
- """重复 CLI 调用检测:防止编排者用同一份 prompt 反复调同一 CLI 死循环。
3
-
4
- 设计稿:docs/migration/01-defense-patterns.md §5C。
5
- 参考 dsh packages/guard/repeat-tool-reminder/src/index.ts:
6
- 监听 tools/post-execute,按 (tool, canonical_args) 跟踪,3/5/8 阈值注入 user-message 提醒。
7
-
8
- Tutti 的 "tool" 对应到 "step role","canonical_args" 对应到 prompt 模板指纹。
9
- 步骤级调用代价远高于普通工具调用,因此第 3 次提醒、第 5 次强制停止。
10
- """
11
- from __future__ import annotations
12
-
13
- import hashlib
14
- import logging
15
- import threading
16
- import time
17
-
18
- log = logging.getLogger(__name__)
19
-
20
- # 渐进阈值:第 N 次命中时给提醒;最后一次及以上时强制停止。
21
- DEFAULT_THRESHOLDS = (3, 5)
22
-
23
- REMINDER_TEMPLATE = (
24
- "⚠️ 提醒:上一步使用了几乎相同的输入(已连续 {count} 次调用 {role})。"
25
- "请换一种方法:读不同的文件、用更具体的指令、或明确询问用户澄清。"
26
- )
27
-
28
- STOP_TEMPLATE = (
29
- "已连续 {count} 次使用相同输入调用 {role},超过 {last_thr} 次阈值,"
30
- "强制停止本 step 以防止无限循环。请用户检查输入或调整 prompt 模板。"
31
- )
32
-
33
-
34
- def _prompt_fingerprint(prompt: str) -> str:
35
- """prompt 指纹:用前 200 字符 + 总长度(CLI prompt 头几行通常是模板部分)。
36
-
37
- 不做严格 canonical args 比对(CLI prompt 通常很长,且 dsh 也用 JSON.stringify);
38
- 指纹命中说明是同一个模板路径走过多次,已经够分辨。
39
-
40
- 用 SHA-256 取前 12 字符(96 bit):48 亿分之一碰撞概率对去重足够;不追求密码学强度。
41
- """
42
- head = (prompt or "")[:200]
43
- return hashlib.sha256(f"{head}|{len(prompt or '')}".encode("utf-8")).hexdigest()[:12]
44
-
45
-
46
- class RepeatGuard:
47
- """每 (run_id, step_role) 独立计数;key = prompt 指纹。
48
-
49
- 计数仅统计相同 key 连续出现;新 prompt 会重置。
50
- """
51
-
52
- def __init__(self, thresholds=DEFAULT_THRESHOLDS, *, max_history=50):
53
- self.thresholds = tuple(sorted(set(thresholds)))
54
- self.max_history = max_history
55
- self._lock = threading.Lock()
56
- # chain[(run_id, role)] = [(ts, fingerprint)]
57
- self._chains: dict = {}
58
-
59
- def check(self, run_id: str, role: str, prompt: str) -> dict:
60
- """检查并更新 (run_id, role) 的连续计数。
61
-
62
- Returns:
63
- {"count": int, "reminder": str | None, "should_stop": bool}
64
- """
65
- fp = _prompt_fingerprint(prompt)
66
- chain_key = (run_id, role)
67
- with self._lock:
68
- chain = self._chains.setdefault(chain_key, [])
69
- chain.append((time.time(), fp))
70
- # 仅统计相同 fp 连续出现
71
- count = 1
72
- for _, prev_fp in reversed(chain[:-1]):
73
- if prev_fp == fp:
74
- count += 1
75
- else:
76
- break
77
- # 截尾避免内存膨胀
78
- if len(chain) > self.max_history:
79
- self._chains[chain_key] = chain[-self.max_history:]
80
-
81
- reminder = None
82
- stop = False
83
- last_thr = self.thresholds[-1]
84
- if count >= last_thr:
85
- stop = True
86
- reminder = STOP_TEMPLATE.format(count=count, role=role, last_thr=last_thr)
87
- elif count in self.thresholds:
88
- reminder = REMINDER_TEMPLATE.format(count=count, role=role)
89
-
90
- if reminder:
91
- log.warning("repeat-guard %s/%s count=%d stop=%s reminder=%s",
92
- run_id, role, count, stop, reminder[:80])
93
- return {"count": count, "reminder": reminder, "should_stop": stop}
94
-
95
- def reset(self, run_id: str) -> int:
96
- """重置某 run_id 的所有 (run_id, *) 链。返回清除的链条数。"""
97
- with self._lock:
98
- cleared = 0
99
- for k in list(self._chains.keys()):
100
- if k[0] == run_id:
101
- del self._chains[k]
102
- cleared += 1
103
- return cleared
104
-
105
- def stats(self, run_id: str, role: str) -> dict:
106
- """读取 (run_id, role) 当前状态(不更新)。用于 UI 调试。"""
107
- chain_key = (run_id, role)
108
- with self._lock:
109
- chain = self._chains.get(chain_key, [])
110
- if not chain:
111
- return {"chain_len": 0, "last_fp": ""}
112
- # 当前连续计数
113
- last_fp = chain[-1][1]
114
- count = 1
115
- for _, fp in reversed(chain[:-1]):
116
- if fp == last_fp:
117
- count += 1
118
- else:
119
- break
120
- return {"chain_len": len(chain), "last_fp": last_fp, "count": count}
121
-
122
-
123
- # 模块级默认实例(pipeline 接线用;run_id 入键天然隔离多任务,测试无需替换)
124
- guard = RepeatGuard()
1
+ # -*- coding: utf-8 -*-
2
+ """重复 CLI 调用检测:防止编排者用同一份 prompt 反复调同一 CLI 死循环。
3
+
4
+ 设计稿:docs/migration/01-defense-patterns.md §5C。
5
+ 参考 dsh packages/guard/repeat-tool-reminder/src/index.ts:
6
+ 监听 tools/post-execute,按 (tool, canonical_args) 跟踪,3/5/8 阈值注入 user-message 提醒。
7
+
8
+ Tutti 的 "tool" 对应到 step role + 执行者身份,"canonical_args" 对应到 prompt
9
+ 模板指纹。不同 CLI 间的换将是有效的回退,不能共享同一个重复预算。步骤级调用
10
+ 代价远高于普通工具调用,因此第 3 次提醒、第 5 次强制停止。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import logging
16
+ import threading
17
+ import time
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ # 渐进阈值:第 N 次命中时给提醒;最后一次及以上时强制停止。
22
+ DEFAULT_THRESHOLDS = (3, 5)
23
+
24
+ REMINDER_TEMPLATE = (
25
+ "⚠️ 提醒:上一步使用了几乎相同的输入(已连续 {count} 次调用 {role})。"
26
+ "请换一种方法:读不同的文件、用更具体的指令、或明确询问用户澄清。"
27
+ )
28
+
29
+ STOP_TEMPLATE = (
30
+ "已连续 {count} 次使用相同输入调用 {role},超过 {last_thr} 次阈值,"
31
+ "强制停止本 step 以防止无限循环。请用户检查输入或调整 prompt 模板。"
32
+ )
33
+
34
+
35
+ def _prompt_fingerprint(prompt: str, identity: str = "") -> str:
36
+ """prompt 指纹:用前 200 字符 + 总长度(CLI prompt 头几行通常是模板部分)。
37
+
38
+ 不做严格 canonical args 比对(CLI prompt 通常很长,且 dsh 也用 JSON.stringify);
39
+ 指纹命中说明是同一个模板路径走过多次,已经够分辨。
40
+
41
+ 用 SHA-256 取前 12 字符(96 bit):48 亿分之一碰撞概率对去重足够;不追求密码学强度。
42
+ """
43
+ head = (prompt or "")[:200]
44
+ return hashlib.sha256(
45
+ f"{identity}\0{head}|{len(prompt or '')}".encode("utf-8")).hexdigest()[:12]
46
+
47
+
48
+ class RepeatGuard:
49
+ """每 (run_id, step_role) 的最近执行链独立计数;指纹包含执行者身份。
50
+
51
+ 计数仅统计相同 key 连续出现;新 prompt 会重置。
52
+ """
53
+
54
+ def __init__(self, thresholds=DEFAULT_THRESHOLDS, *, max_history=50):
55
+ self.thresholds = tuple(sorted(set(thresholds)))
56
+ self.max_history = max_history
57
+ self._lock = threading.Lock()
58
+ # chain[(run_id, role)] = [(ts, fingerprint)]
59
+ self._chains: dict = {}
60
+
61
+ def check(self, run_id: str, role: str, prompt: str, *, identity: str = "") -> dict:
62
+ """检查并更新 (run_id, role) 的连续计数。
63
+
64
+ identity 标识实际执行 CLI/agent。相同 prompt 换到其他执行者属于回退,
65
+ 会从新的连续链计数;同一执行者重复相同调用仍按阈值停止。
66
+
67
+ Returns:
68
+ {"count": int, "reminder": str | None, "should_stop": bool}
69
+ """
70
+ fp = _prompt_fingerprint(prompt, str(identity or ""))
71
+ chain_key = (run_id, role)
72
+ with self._lock:
73
+ chain = self._chains.setdefault(chain_key, [])
74
+ chain.append((time.time(), fp))
75
+ # 仅统计相同 fp 连续出现
76
+ count = 1
77
+ for _, prev_fp in reversed(chain[:-1]):
78
+ if prev_fp == fp:
79
+ count += 1
80
+ else:
81
+ break
82
+ # 截尾避免内存膨胀
83
+ if len(chain) > self.max_history:
84
+ self._chains[chain_key] = chain[-self.max_history:]
85
+
86
+ reminder = None
87
+ stop = False
88
+ last_thr = self.thresholds[-1]
89
+ if count >= last_thr:
90
+ stop = True
91
+ reminder = STOP_TEMPLATE.format(count=count, role=role, last_thr=last_thr)
92
+ elif count in self.thresholds:
93
+ reminder = REMINDER_TEMPLATE.format(count=count, role=role)
94
+
95
+ if reminder:
96
+ log.warning("repeat-guard %s/%s count=%d stop=%s reminder=%s",
97
+ run_id, role, count, stop, reminder[:80])
98
+ return {"count": count, "reminder": reminder, "should_stop": stop}
99
+
100
+ def reset(self, run_id: str) -> int:
101
+ """重置某 run_id 的所有 (run_id, *) 链。返回清除的链条数。"""
102
+ with self._lock:
103
+ cleared = 0
104
+ for k in list(self._chains.keys()):
105
+ if k[0] == run_id:
106
+ del self._chains[k]
107
+ cleared += 1
108
+ return cleared
109
+
110
+ def stats(self, run_id: str, role: str) -> dict:
111
+ """读取 (run_id, role) 当前状态(不更新)。用于 UI 调试。"""
112
+ chain_key = (run_id, role)
113
+ with self._lock:
114
+ chain = self._chains.get(chain_key, [])
115
+ if not chain:
116
+ return {"chain_len": 0, "last_fp": ""}
117
+ # 当前连续计数
118
+ last_fp = chain[-1][1]
119
+ count = 1
120
+ for _, fp in reversed(chain[:-1]):
121
+ if fp == last_fp:
122
+ count += 1
123
+ else:
124
+ break
125
+ return {"chain_len": len(chain), "last_fp": last_fp, "count": count}
126
+
127
+
128
+ # 模块级默认实例(pipeline 接线用;run_id 入键天然隔离多任务,测试无需替换)
129
+ guard = RepeatGuard()
@@ -0,0 +1,51 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Small, content-addressed revision helpers.
3
+
4
+ The execution layer stores revision identifiers and digests instead of copying
5
+ skill/knowledge bodies into every run. A digest is deliberately based on the
6
+ semantic payload supplied by the caller, so formatting-only metadata changes do
7
+ not silently change the meaning of an existing run.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import time
14
+
15
+
16
+ def canonical_json(value) -> str:
17
+ return json.dumps(value, ensure_ascii=False, sort_keys=True,
18
+ separators=(",", ":"), default=str)
19
+
20
+
21
+ def content_sha256(value) -> str:
22
+ if not isinstance(value, str):
23
+ value = canonical_json(value)
24
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
25
+
26
+
27
+ def revision_id(prefix: str, digest: str) -> str:
28
+ return "%s-%s" % (str(prefix or "rev"), str(digest or "")[:16])
29
+
30
+
31
+ def make_revision(prefix: str, content, *, source="", created_at=None) -> dict:
32
+ digest = content_sha256(content)
33
+ return {
34
+ "revision_id": revision_id(prefix, digest),
35
+ "content_sha256": digest,
36
+ "source": str(source or "")[:160],
37
+ "created_at": created_at or time.strftime("%Y-%m-%d %H:%M:%S"),
38
+ }
39
+
40
+
41
+ def revision_record(prefix: str, content, *, source="", created_at=None,
42
+ extra=None) -> dict:
43
+ out = make_revision(prefix, content, source=source, created_at=created_at)
44
+ if isinstance(extra, dict):
45
+ out.update(extra)
46
+ return out
47
+
48
+
49
+ def stable_digest(items) -> str:
50
+ """Digest a list/dict of revision metadata without embedding content."""
51
+ return content_sha256(items)
@@ -2,11 +2,11 @@
2
2
  """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
3
  from __future__ import annotations
4
4
 
5
- import os
6
- import re
7
- import json
5
+ import json
6
+ import os
7
+ import re
8
8
 
9
- from . import dispatch, history
9
+ from . import dispatch, history, upstream
10
10
 
11
11
  # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
12
12
  CAPABILITY = {
@@ -57,8 +57,9 @@ def _history_bonus(stats, agent_id, ttype):
57
57
  runs = s["runs"]
58
58
  win_rate = s["wins"] / runs
59
59
  # 胜率为主,经验为辅;败率要扣分且随样本量爬满(前 3 次线性加权,防一次
60
- # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
61
- # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
60
+ # 偶发失败把智能体埋了)。扣分项封顶 -24,0/3 全败净 -21(含 +3 经验分)、
61
+ # 0/6 净 -18:常挂的 CLI 必须排到无历史的新面孔之后(2026-09-17 前败率不
62
+ # 扣分,0/3 还拿 +6 经验分,比没跑过还高)。
62
63
  loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
63
64
  return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
64
65
 
@@ -85,6 +86,9 @@ def _online_bonus(agent, role, ttype):
85
86
  reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
86
87
  % (int(metrics.get("successes") or 0), success_samples,
87
88
  success_score, p95, latency_score, cost, cost_score))
89
+ # 标准要求回退层出现在候选理由里(成本与时延预算第 5 条):
90
+ # exact/task-role/task/global,让"分数来自哪层样本"可核对。
91
+ reason += ",样本层 %s" % (metrics.get("fallback") or "global")
88
92
  return total, reason
89
93
  except Exception:
90
94
  return 0.0, ""
@@ -145,77 +149,102 @@ def pick(agents, role, ttype, stats=None, exclude=()):
145
149
  # 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
146
150
  # 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
147
151
  # 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
148
- _LOCAL_ENDPOINT_PROBES = (
152
+ _LOCAL_ENDPOINT_PROBES = (
149
153
  ("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
150
154
  ("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
151
155
  ("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
152
156
  ("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
153
157
  ("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
154
158
  ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
155
- )
156
-
157
- _PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
159
+ )
160
+
161
+ _PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
162
+ _PI_MODELS_PATH = "~/.pi/agent/models.json"
163
+
164
+
165
+ def _pi_provider_hosts(selected_name):
166
+ """按选中名取 pi 供应商块的 host:port(先 models.json,再 settings.json 兜底)。
167
+
168
+ 老配置可能把 providers 直接写进 settings.json,两件都认;只认选中的那块。"""
169
+ hosts = set()
170
+ if not selected_name:
171
+ return hosts
172
+ for rel in (_PI_MODELS_PATH, _PI_SETTINGS_PATH):
173
+ try:
174
+ with open(os.path.expanduser(rel), "r", encoding="utf-8") as fh:
175
+ cfg = json.load(fh)
176
+ except (OSError, ValueError, TypeError):
177
+ continue
178
+ if not isinstance(cfg, dict):
179
+ continue
180
+ block = (cfg.get("providers") or {}).get(selected_name)
181
+ if not isinstance(block, dict):
182
+ continue
183
+ h = _host_of(block.get("baseUrl") or block.get("baseURL") or
184
+ block.get("base_url"))
185
+ if h:
186
+ hosts.add(h)
187
+ break # 命中即定:models.json 优先,别让另一件的旧块参与判断
188
+ return hosts
158
189
 
159
190
 
160
191
  def _host_of(url):
161
- """上游归一:只留 host:port。/api/anthropic 与 /v1 的路径差异不算换上游。"""
162
- m = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", str(url or "").strip())
163
- return m.group(1).lower() if m else ""
164
-
165
-
166
- def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
167
- """候选实际会用到的上游集合(host:port 归一)。显式链 > CLI 本机配置
168
- 端点;无显式绑定时按运行期自动推荐链判断;都拿不到返回空集 = 未知。"""
169
- ups = set()
170
- try:
171
- from . import modelhub
172
- pref = modelhub._binding_for(agent_id)
173
- provs = {p.get("id"): p for p in modelhub.providers()}
174
-
175
- def _add_chain(chain):
176
- for item in chain or ():
177
- pid = (item.get("provider_id") or "").strip()
178
- provider = item.get("provider") or provs.get(pid) or {}
179
- h = _host_of(provider.get("base_url"))
180
- if h:
181
- ups.add(h)
182
-
183
- configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
184
- # 保留原始显式链中的 endpoint,即使某条已禁用/失效;同一个死上游仍
185
- # 不值得在另一个 CLI 上立刻再撞一次。
186
- if configured:
187
- chain = modelhub._binding_chain(pref)
188
- _add_chain(chain)
189
- resolved = modelhub.resolve_binding(agent_id, difficulty) or {}
190
- _add_chain(resolved.get("call_chain"))
191
- else:
192
- # bind_agent 在空绑定时会自动推荐供应商链。换将选路必须观察同一
193
- # 生效链,否则“空绑定”的多个 CLI 可能被排成不同候选却共用一个 403 网关。
194
- recommended = modelhub.recommend_binding(
195
- agent_id, difficulty, task_type=task_type, role=role) or {}
196
- _add_chain(recommended.get("call_chain"))
197
- except Exception:
198
- pass
199
- if not ups:
200
- if agent_id == "pi":
201
- # Pi 将 endpoint 放在 providers.<defaultProvider>.baseUrl,不能只扫
202
- # 所有 baseUrl(那会把未选中的 provider 也误算成当前上游)。
203
- try:
204
- with open(os.path.expanduser(_PI_SETTINGS_PATH), "r",
205
- encoding="utf-8") as fh:
206
- cfg = json.load(fh)
207
- selected = (cfg.get("providers") or {}).get(
208
- cfg.get("defaultProvider") or "") or {}
209
- h = _host_of(selected.get("baseUrl") or selected.get("baseURL") or
210
- selected.get("base_url"))
211
- if h:
212
- ups.add(h)
213
- except (OSError, ValueError, TypeError):
214
- pass
215
- if not ups:
216
- for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
217
- if aid != agent_id:
218
- continue
192
+ """上游归一:路径、认证信息和默认端口不影响同一上游身份。"""
193
+ return upstream.normalize_upstream(url)
194
+
195
+
196
+ def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
197
+ """候选实际会用到的上游集合(host:port 归一)。显式链优先;空绑定按
198
+ 运行期自动推荐链判断,再回落到 CLI 本机配置;都拿不到返回空集 = 未知。"""
199
+ ups = set()
200
+ try:
201
+ from . import modelhub
202
+ pref = modelhub._binding_for(agent_id)
203
+ provs = {p.get("id"): p for p in modelhub.providers()}
204
+
205
+ def _add_chain(chain):
206
+ for item in chain or ():
207
+ pid = (item.get("provider_id") or "").strip()
208
+ provider = item.get("provider") or provs.get(pid) or {}
209
+ h = _host_of(provider.get("base_url"))
210
+ if h:
211
+ ups.add(h)
212
+
213
+ configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
214
+ # 保留原始显式链中的 endpoint,即使某条已禁用/失效;同一个死上游仍
215
+ # 不值得在另一个 CLI 上立刻再撞一次。
216
+ if configured:
217
+ chain = modelhub._binding_chain(pref)
218
+ _add_chain(chain)
219
+ resolved = modelhub.resolve_binding(agent_id, difficulty) or {}
220
+ _add_chain(resolved.get("call_chain"))
221
+ else:
222
+ # bind_agent 在空绑定时会自动推荐供应商链。换将选路必须观察同一
223
+ # 生效链,否则“空绑定”的多个 CLI 可能被排成不同候选却共用一个 403 网关。
224
+ recommended = modelhub.recommend_binding(
225
+ agent_id, difficulty, task_type=task_type, role=role) or {}
226
+ _add_chain(recommended.get("call_chain"))
227
+ except Exception:
228
+ pass
229
+ if not ups:
230
+ if agent_id == "pi":
231
+ # Pi 的选择位在 settings.json(defaultProvider),而 endpoint 在**同目录
232
+ # models.json** 的 providers.<选中名>.baseUrl(pi 的 getModelsPath(),
233
+ # CodeBee 托管与用户手工配置都落在那儿)——不能只扫所有 baseUrl,
234
+ # 那会把未选中的 provider 也误算成当前上游。
235
+ try:
236
+ with open(os.path.expanduser(_PI_SETTINGS_PATH), "r",
237
+ encoding="utf-8") as fh:
238
+ settings = json.load(fh)
239
+ selected_name = str((settings.get("defaultProvider")
240
+ if isinstance(settings, dict) else "") or "")
241
+ ups.update(_pi_provider_hosts(selected_name))
242
+ except (OSError, ValueError, TypeError):
243
+ pass
244
+ if not ups:
245
+ for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
246
+ if aid != agent_id:
247
+ continue
219
248
  try:
220
249
  with open(os.path.expanduser(path), "r", encoding="utf-8",
221
250
  errors="ignore") as fh:
@@ -229,20 +258,28 @@ def agent_upstreams(agent_id, difficulty="default", task_type="", role=""):
229
258
  return ups
230
259
 
231
260
 
232
- def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
233
- dead_upstreams=(), difficulty="default"):
261
+ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
262
+ dead_upstreams=(), difficulty="default",
263
+ blocked_upstreams=()):
234
264
  """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
235
265
  异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
236
266
  之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
237
- 候选捡回分数最高者——聊胜于无。"""
267
+ 候选捡回分数最高者——聊胜于无。403 明确拒绝的上游始终阻断,不捡回重试。"""
238
268
  exclude = set(exclude)
269
+ blocked_sets = [set(d) for d in (blocked_upstreams or ()) if d]
239
270
  best, reason = pick(agents, role, ttype, stats, exclude=exclude)
240
- if best is None or not dead_upstreams:
271
+ if best is None or (not dead_upstreams and not blocked_sets):
241
272
  return best, reason
242
- deferred = []
243
- while best is not None:
244
- ups = agent_upstreams(best.get("id"), difficulty=difficulty,
245
- task_type=ttype, role=role)
273
+ deferred = []
274
+ while best is not None:
275
+ ups = agent_upstreams(best.get("id"), difficulty=difficulty,
276
+ task_type=ttype, role=role)
277
+ if ups and any(ups & blocked for blocked in blocked_sets):
278
+ # 明确的 403 权限拒绝:同一上游没有换模型/换 CLI 再试的价值,
279
+ # 即使异上游候选耗尽也不把它捡回来(与可换账号的配额错误不同)。
280
+ exclude.add(best.get("id"))
281
+ best, reason = pick(agents, role, ttype, stats, exclude=exclude)
282
+ continue
246
283
  if not (ups and any(ups & dead for dead in dead_upstreams)):
247
284
  break # 异上游或上游未知:就用它
248
285
  deferred.append((best, reason))
@@ -301,14 +338,14 @@ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
301
338
 
302
339
 
303
340
  def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
304
- dead_upstreams=()):
341
+ dead_upstreams=(), difficulty="default"):
305
342
  """评审者:跨厂商是硬规则(Codeband 的对抗式配对)——同族评审有同款盲区,
306
343
  评审者必须来自与实现者不同的 kind;跨族池为空才回退同厂商并如实备注,
307
344
  绝不把回退伪装成跨厂商。exclude 用于剔除运行时已知死候选(实现步走查证伪
308
- 的 quota/限流死链),与 pick / pick_switch_candidate 的 exclude 同语义。
345
+ 的 quota/403 死链),与 pick / pick_switch_candidate 的 exclude 同语义。
309
346
  dead_upstreams 进一步剔除「与死者同上游」的候选——配额通常按网关/账号烧刻,
310
- 同上游 = 同配额桶,评审者又是单点,撞死链会白烧一轮并误报「评审器故障」,
311
- 故宁可少一个候选也不选已知-同上游的。上游未知不剔除(宁白试不误杀)。"""
347
+ 同上游 = 同配额桶,403 权限拒绝也不能换壳重试。评审者又是单点,撞死链会
348
+ 白烧一轮并误报「评审器故障」,故宁可少一个候选也不选已知同上游的。"""
312
349
  stats = stats or history.agent_stats()
313
350
  excluded = set(exclude)
314
351
  dead_sets = [set(d) for d in (dead_upstreams or ()) if d]
@@ -318,7 +355,8 @@ def pick_reviewer(agents, impl, ttype, stats=None, exclude=(),
318
355
  return False
319
356
  if not dead_sets:
320
357
  return True
321
- ups = agent_upstreams(a.get("id"))
358
+ ups = agent_upstreams(a.get("id"), difficulty=difficulty,
359
+ task_type=ttype, role="review")
322
360
  return not (ups and any(ups & d for d in dead_sets))
323
361
 
324
362
  impl_kind = impl.get("kind")