codebee 0.1.64 → 0.1.65

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.
@@ -1060,7 +1060,9 @@ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
1060
1060
  failures = ["%s:%s" % (impl.get("id") or "CLI",
1061
1061
  (draft_res.get("error") or "起草失败")[:180])]
1062
1062
  dead_upstreams = []
1063
- first_upstreams = router.agent_upstreams(impl.get("id"))
1063
+ first_upstreams = router.agent_upstreams(
1064
+ impl.get("id"), difficulty=difficulty,
1065
+ task_type=task.get("type") or "novel", role="implement")
1064
1066
  if first_upstreams:
1065
1067
  dead_upstreams.append(first_upstreams)
1066
1068
  current = impl
@@ -1072,7 +1074,7 @@ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
1072
1074
  _check_cancel(ev)
1073
1075
  candidate, reason = router.pick_switch_candidate(
1074
1076
  agents, "implement", task.get("type") or "novel", stats,
1075
- exclude=tried, dead_upstreams=dead_upstreams)
1077
+ exclude=tried, dead_upstreams=dead_upstreams, difficulty=difficulty)
1076
1078
  if candidate is None or candidate.get("id") in tried:
1077
1079
  break
1078
1080
  tried.add(candidate.get("id"))
@@ -1097,7 +1099,9 @@ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
1097
1099
  (last_result.get("error") or "起草失败")[:180]))
1098
1100
  if (last_result.get("raw") or {}).get("cancelled"):
1099
1101
  break
1100
- ups = router.agent_upstreams(candidate.get("id"))
1102
+ ups = router.agent_upstreams(
1103
+ candidate.get("id"), difficulty=difficulty,
1104
+ task_type=task.get("type") or "novel", role="implement")
1101
1105
  if ups:
1102
1106
  dead_upstreams.append(ups)
1103
1107
  current = candidate
@@ -1309,8 +1313,11 @@ def _run_code(run, task, agents, ev, stats, mode):
1309
1313
  # 原实现者若死于配额,其上游(网关/账号)也一并判入死池——否则第一棒
1310
1314
  # 换将不知道同网关候选已死,评审者重选更会把「与死者同上游」的候选
1311
1315
  # 继续当活口,评审撞同一条失效网关白烧一轮后误报「评审器故障」。
1312
- if runner._quota_error(res.get("error") or ""):
1313
- ups0 = router.agent_upstreams(impl_agent["id"])
1316
+ if (runner._quota_error(res.get("error") or "")
1317
+ or runner._permission_error(res.get("error") or "")):
1318
+ ups0 = router.agent_upstreams(
1319
+ impl_agent["id"], difficulty=difficulty,
1320
+ task_type="code", role="implement")
1314
1321
  if ups0:
1315
1322
  dead_ups.append(ups0)
1316
1323
  prev_id = impl_agent["id"]
@@ -1318,7 +1325,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1318
1325
  while True:
1319
1326
  other, other_reason = router.pick_switch_candidate(
1320
1327
  agents, "implement", "code", stats,
1321
- exclude=tried, dead_upstreams=dead_ups)
1328
+ exclude=tried, dead_upstreams=dead_ups,
1329
+ difficulty=difficulty)
1322
1330
  if other is None or other.get("mode") != "real":
1323
1331
  other = None
1324
1332
  break
@@ -1348,8 +1356,11 @@ def _run_code(run, task, agents, ev, stats, mode):
1348
1356
  tried.add(other["id"])
1349
1357
  dead_ids.add(other["id"])
1350
1358
  prev_id = other["id"]
1351
- if runner._quota_error(res.get("error") or ""):
1352
- ups = router.agent_upstreams(other["id"])
1359
+ if (runner._quota_error(res.get("error") or "")
1360
+ or runner._permission_error(res.get("error") or "")):
1361
+ ups = router.agent_upstreams(
1362
+ other["id"], difficulty=difficulty,
1363
+ task_type="code", role="implement")
1353
1364
  if ups:
1354
1365
  dead_ups.append(ups)
1355
1366
  else:
@@ -2,8 +2,9 @@
2
2
  """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
3
  from __future__ import annotations
4
4
 
5
- import os
6
- import re
5
+ import os
6
+ import re
7
+ import json
7
8
 
8
9
  from . import dispatch, history
9
10
 
@@ -144,14 +145,16 @@ def pick(agents, role, ttype, stats=None, exclude=()):
144
145
  # 空链 CLI 的真实上游在各自本机配置里(注册表里的 provider_id 是 UI 残留,
145
146
  # 2026-09-22 实测 qwencode 注册表指 prov-36 本机却指公司网关)。正则探针
146
147
  # 只为换将时识别「同上游」,读不到就算未知——未知不参与剔除,宁白试不误杀。
147
- _LOCAL_ENDPOINT_PROBES = (
148
+ _LOCAL_ENDPOINT_PROBES = (
148
149
  ("kimi-code", "~/.kimi-code/config.toml", r'baseUrl\s*=\s*"([^"]+)"'),
149
150
  ("claude-code", "~/.claude/settings.json", r'"ANTHROPIC_BASE_URL"\s*:\s*"([^"]+)"'),
150
151
  ("qwencode", "~/.qwen/settings.json", r'"OPENAI_BASE_URL"\s*:\s*"([^"]+)"'),
151
152
  ("opencode", "~/.config/opencode/opencode.json", r'"baseURL"\s*:\s*"([^"]+)"'),
152
153
  ("opencode", "~/.config/opencode/opencode.jsonc", r'"baseURL"\s*:\s*"([^"]+)"'),
153
154
  ("codex-cli", "~/.codex/config.toml", r'base_url\s*=\s*"([^"]+)"'),
154
- )
155
+ )
156
+
157
+ _PI_SETTINGS_PATH = "~/.pi/agent/settings.json"
155
158
 
156
159
 
157
160
  def _host_of(url):
@@ -160,27 +163,59 @@ def _host_of(url):
160
163
  return m.group(1).lower() if m else ""
161
164
 
162
165
 
163
- def agent_upstreams(agent_id):
164
- """候选实际会用到的上游集合(host:port 归一)。显式链 > CLI 本机配置
165
- 端点;两者都拿不到返回空集 = 未知。"""
166
- ups = set()
167
- try:
168
- from . import modelhub
169
- pref = modelhub._binding_for(agent_id)
170
- chain = modelhub._binding_chain(pref)
171
- provs = {p.get("id"): p for p in modelhub.providers()}
172
- if chain:
173
- for item in chain:
174
- pid = (item.get("provider_id") or "").strip()
175
- h = _host_of((provs.get(pid) or {}).get("base_url"))
176
- if h:
177
- ups.add(h)
178
- except Exception:
179
- pass
180
- if not ups:
181
- for aid, path, pat in _LOCAL_ENDPOINT_PROBES:
182
- if aid != agent_id:
183
- continue
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
184
219
  try:
185
220
  with open(os.path.expanduser(path), "r", encoding="utf-8",
186
221
  errors="ignore") as fh:
@@ -194,8 +229,8 @@ def agent_upstreams(agent_id):
194
229
  return ups
195
230
 
196
231
 
197
- def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
198
- dead_upstreams=()):
232
+ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
233
+ dead_upstreams=(), difficulty="default"):
199
234
  """换将选将。配额类死亡背景下:与死者同上游的候选依次让位,直到找到
200
235
  异上游/上游未知的候选(2026-09-22 实案:kimi 与 claude 同骑智谱,0.1 分
201
236
  之差把异上游 qwencode 压在下面,换将=换壳不换命)。异上游耗尽后同上游
@@ -204,9 +239,10 @@ def pick_switch_candidate(agents, role, ttype, stats, exclude=(),
204
239
  best, reason = pick(agents, role, ttype, stats, exclude=exclude)
205
240
  if best is None or not dead_upstreams:
206
241
  return best, reason
207
- deferred = []
208
- while best is not None:
209
- ups = agent_upstreams(best.get("id"))
242
+ deferred = []
243
+ while best is not None:
244
+ ups = agent_upstreams(best.get("id"), difficulty=difficulty,
245
+ task_type=ttype, role=role)
210
246
  if not (ups and any(ups & dead for dead in dead_upstreams)):
211
247
  break # 异上游或上游未知:就用它
212
248
  deferred.append((best, reason))
@@ -1043,6 +1043,17 @@ def _auth_error(err):
1043
1043
  return any(k in err for k in _AUTH)
1044
1044
 
1045
1045
 
1046
+ _FORBIDDEN_HINTS = ("forbidden", "request not allowed", "permission denied",
1047
+ "access denied")
1048
+
1049
+
1050
+ def _permission_error(err):
1051
+ """识别明确的 HTTP 403 权限拒绝;它是上游故障,不等同于无效 API Key。"""
1052
+ err = (err or "").lower()
1053
+ return bool(re.search(r"(?<!\d)403(?!\d)", err) and
1054
+ any(marker in err for marker in _FORBIDDEN_HINTS))
1055
+
1056
+
1046
1057
  def _attempt_credential(att):
1047
1058
  """绑定链中凭据的非敏感身份;不把密钥值写入日志或尝试记录。"""
1048
1059
  return (str((att or {}).get("provider_id") or ""),
@@ -1621,6 +1632,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1621
1632
  grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1622
1633
  timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
1623
1634
  skipped_upstreams = set()
1635
+ forbidden_upstreams = set()
1624
1636
  auth_failed_credentials = set()
1625
1637
  for ai, att in enumerate(attempts):
1626
1638
  credential = _attempt_credential(att)
@@ -1638,7 +1650,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1638
1650
  out["attempts"] = list(attempt_history)
1639
1651
  continue
1640
1652
  upstream = _attempt_upstream(att)
1641
- if upstream in skipped_upstreams:
1653
+ if upstream in skipped_upstreams or upstream in forbidden_upstreams:
1642
1654
  continue
1643
1655
  # 有任务 deadline 时,所有候选共享同一个截止时刻;否则每个候选
1644
1656
  # 使用 catalog 配置或 caller 提供的单模型时限。
@@ -1763,7 +1775,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1763
1775
  except Exception:
1764
1776
  pass
1765
1777
  if not (_quota_error(out["error"]) or _transient_error(out["error"])
1766
- or _rate_limited(out["error"])):
1778
+ or _rate_limited(out["error"])
1779
+ or _permission_error(out["error"])):
1767
1780
  # 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
1768
1781
  # 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
1769
1782
  # (真错误只走了流式管道),路由层看不到配额关键词,换将
@@ -1867,6 +1880,15 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1867
1880
  for candidate in attempts[ai + 1:]):
1868
1881
  return out
1869
1882
  continue
1883
+ if _permission_error(out.get("error")):
1884
+ # 403 通常是该网关/上游拒绝当前请求;同一 host 换模型只会重撞,
1885
+ # 但不同 host 的候选仍可能有权限,应继续尝试异上游链项。
1886
+ if upstream != "unknown":
1887
+ forbidden_upstreams.add(upstream)
1888
+ if not any(_attempt_upstream(candidate) not in forbidden_upstreams
1889
+ for candidate in attempts[ai + 1:]):
1890
+ return out
1891
+ continue
1870
1892
  # 换将闸门:什么失败值得烧链上下一个候选。
1871
1893
  # · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
1872
1894
  # · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
@@ -1879,6 +1901,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1879
1901
  if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
1880
1902
  or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
1881
1903
  or _refusal_error(out.get("error"))
1904
+ or _permission_error(out.get("error"))
1882
1905
  or raw.get("timed_out"))):
1883
1906
  return out # 取消/解析失败等真终态不降级
1884
1907
  # 同一上游的模型连续两次超时就收手——
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.64",
3
+ "version": "0.1.65",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {