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.
- package/CHANGELOG.md +6 -0
- package/README.md +5 -5
- package/app/core/bookmeta.py +544 -526
- package/app/core/pipeline.py +19 -8
- package/app/core/router.py +66 -30
- package/app/core/runner.py +25 -2
- package/package.json +1 -1
package/app/core/pipeline.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/app/core/router.py
CHANGED
|
@@ -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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for item in chain:
|
|
174
|
-
pid = (item.get("provider_id") or "").strip()
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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))
|
package/app/core/runner.py
CHANGED
|
@@ -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