codebee 0.1.63 → 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 +14 -0
- package/README.md +5 -2
- package/app/core/artifacts.py +148 -0
- package/app/core/bookmeta.py +759 -545
- package/app/core/modelhub.py +76 -10
- package/app/core/pipeline.py +61 -11
- package/app/core/router.py +66 -30
- package/app/core/runner.py +33 -5
- package/app/core/store.py +13 -70
- package/app/core/zentao.py +386 -29
- package/app/main.py +8 -2
- package/package.json +1 -1
package/app/core/modelhub.py
CHANGED
|
@@ -2037,6 +2037,35 @@ def _model_image_in(prov, model):
|
|
|
2037
2037
|
return False
|
|
2038
2038
|
|
|
2039
2039
|
|
|
2040
|
+
def vision_candidates(prefer_provider_id="", prefer_model=""):
|
|
2041
|
+
"""全库搜「能看图」的候选模型,供图类调用自动降级(编排模型不支持看图时
|
|
2042
|
+
自己换将,不甩人工)。返回有序 [(prov, model_name, inferred)]:
|
|
2043
|
+
声明 image_in 的在先;仅名字推断(_auto_image_in)的在后(inferred=True,
|
|
2044
|
+
调用方实测成功后应 set_model_caps 落标记)。编排者供应商排最前,
|
|
2045
|
+
编排模型本身若可看图排整个列表第一。"""
|
|
2046
|
+
out, seen = [], set()
|
|
2047
|
+
provs = [p for p in providers()
|
|
2048
|
+
if isinstance(p, dict) and p.get("enabled", True) and p.get("api_key")]
|
|
2049
|
+
provs.sort(key=lambda p: 0 if p.get("id") == prefer_provider_id else 1)
|
|
2050
|
+
for p in provs:
|
|
2051
|
+
for m in _enabled_models(p):
|
|
2052
|
+
name = str(m.get("name") or "")
|
|
2053
|
+
if not name or (p.get("id"), name) in seen:
|
|
2054
|
+
continue
|
|
2055
|
+
if bool(m.get("image_in")):
|
|
2056
|
+
inferred = False
|
|
2057
|
+
elif _auto_image_in(name):
|
|
2058
|
+
inferred = True
|
|
2059
|
+
else:
|
|
2060
|
+
continue
|
|
2061
|
+
seen.add((p.get("id"), name))
|
|
2062
|
+
out.append((p, name, inferred))
|
|
2063
|
+
if prefer_model:
|
|
2064
|
+
out.sort(key=lambda x: 0 if (x[0].get("id") == prefer_provider_id
|
|
2065
|
+
and x[1] == prefer_model) else 1)
|
|
2066
|
+
return out
|
|
2067
|
+
|
|
2068
|
+
|
|
2040
2069
|
def bind_agent(agent, difficulty="default", task_type="", role=""):
|
|
2041
2070
|
"""按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。
|
|
2042
2071
|
|
|
@@ -3096,32 +3125,68 @@ def resolve_orchestrator():
|
|
|
3096
3125
|
return prov, model
|
|
3097
3126
|
|
|
3098
3127
|
|
|
3099
|
-
def
|
|
3100
|
-
"""
|
|
3128
|
+
def _chat_user_content(proto, prompt, images):
|
|
3129
|
+
"""images=[(mime, b64)] 时构造多模态 user content;无图返回原样文本。
|
|
3130
|
+
|
|
3131
|
+
openai/anthropic 的 content 变数组块,google 走 parts;形状与
|
|
3132
|
+
builtin_agent 的对话消息一致(图只进 user 消息)。"""
|
|
3133
|
+
if not images:
|
|
3134
|
+
return [{"text": prompt}] if proto == "google" else prompt
|
|
3135
|
+
if proto == "google":
|
|
3136
|
+
parts = [{"text": prompt}]
|
|
3137
|
+
for mime, b64 in images:
|
|
3138
|
+
parts.append({"inline_data": {"mime_type": mime, "data": b64}})
|
|
3139
|
+
return parts
|
|
3140
|
+
blocks = [{"type": "text", "text": prompt}]
|
|
3141
|
+
for mime, b64 in images:
|
|
3142
|
+
if proto == "anthropic":
|
|
3143
|
+
blocks.append({"type": "image",
|
|
3144
|
+
"source": {"type": "base64", "media_type": mime,
|
|
3145
|
+
"data": b64}})
|
|
3146
|
+
else:
|
|
3147
|
+
blocks.append({"type": "image_url",
|
|
3148
|
+
"image_url": {"url": "data:%s;base64,%s" % (mime, b64)}})
|
|
3149
|
+
return blocks
|
|
3150
|
+
|
|
3151
|
+
|
|
3152
|
+
def _chat_cache_path(provider_id, model_name, prompt, max_tokens, reasoning_effort="",
|
|
3153
|
+
images=None):
|
|
3154
|
+
"""§07 T2.2:精确匹配响应缓存的落盘路径(只缓存 ok 的幂等调用)。
|
|
3155
|
+
images 参与键(同 prompt 不同图的响应可能不同)。"""
|
|
3101
3156
|
import hashlib as _h
|
|
3157
|
+
img_sig = ""
|
|
3158
|
+
if images:
|
|
3159
|
+
try:
|
|
3160
|
+
img_sig = _h.sha256("|".join("%s|%s" % (m, b) for m, b in images)
|
|
3161
|
+
.encode("utf-8")).hexdigest()[:16]
|
|
3162
|
+
except Exception:
|
|
3163
|
+
img_sig = "err"
|
|
3102
3164
|
key = "|".join([str(provider_id), str(model_name), str(max_tokens),
|
|
3103
|
-
str(reasoning_effort or ""), str(prompt)])
|
|
3165
|
+
str(reasoning_effort or ""), str(prompt), img_sig])
|
|
3104
3166
|
name = _h.sha256(key.encode("utf-8")).hexdigest()[:24]
|
|
3105
3167
|
return paths.DATA_DIR / "chat_cache" / (name + ".json")
|
|
3106
3168
|
|
|
3107
3169
|
|
|
3108
3170
|
def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_ttl=0,
|
|
3109
|
-
on_delta=None, reasoning_effort=""):
|
|
3171
|
+
on_delta=None, reasoning_effort="", images=None):
|
|
3110
3172
|
"""直连供应商 API 做一次对话(编排者规划 / 连通性测试)。
|
|
3111
3173
|
|
|
3112
3174
|
支持 anthropic / openai / google 三种协议;复用 SSRF 防护。
|
|
3175
|
+
images=[(mime, b64)] 时随 user 消息传图(三协议形状见 _chat_user_content;
|
|
3176
|
+
图只在非流式路径验证过,流式 on_delta 与图并用未验证)。
|
|
3113
3177
|
返回 {ok, text, tokens, usage, error};usage 为细分 {input, output, cached, reasoning, total}。
|
|
3114
|
-
cache_ttl>0 启用精确匹配响应缓存(key=供应商+模型+prompt+max_tokens
|
|
3115
|
-
ok 结果)——仅限幂等调用(连通性测试等);创作类调用不要开,否则同一 prompt
|
|
3178
|
+
cache_ttl>0 启用精确匹配响应缓存(key=供应商+模型+prompt+max_tokens+图签名,
|
|
3179
|
+
只缓存 ok 结果)——仅限幂等调用(连通性测试等);创作类调用不要开,否则同一 prompt
|
|
3116
3180
|
的二次请求会屏蔽模型的新输出。
|
|
3117
3181
|
on_delta 给定时走 SSE 流式:每收到一段增量文本回调一次。编排者直连调用
|
|
3118
3182
|
不经 run_process、原本生成全程日志只有一行标题,靠它把「正在吐字」实时
|
|
3119
3183
|
写进步骤日志(planner._log_streamer 节流落盘)。
|
|
3120
3184
|
"""
|
|
3185
|
+
images = [(str(m), str(b)) for m, b in (images or [])] or None
|
|
3121
3186
|
if cache_ttl > 0:
|
|
3122
3187
|
try:
|
|
3123
3188
|
cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens,
|
|
3124
|
-
reasoning_effort)
|
|
3189
|
+
reasoning_effort, images)
|
|
3125
3190
|
if cache_path.is_file():
|
|
3126
3191
|
age = time.time() - cache_path.stat().st_mtime
|
|
3127
3192
|
if age <= cache_ttl:
|
|
@@ -3157,7 +3222,7 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
|
|
|
3157
3222
|
else:
|
|
3158
3223
|
url = base + "/v1beta/models/%s:generateContent" % model_name
|
|
3159
3224
|
headers = {"x-goog-api-key": use_key}
|
|
3160
|
-
body = {"contents": [{"parts":
|
|
3225
|
+
body = {"contents": [{"parts": _chat_user_content(proto, prompt, images)}],
|
|
3161
3226
|
"generationConfig": {"maxOutputTokens": max_tokens}}
|
|
3162
3227
|
else:
|
|
3163
3228
|
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
@@ -3167,7 +3232,8 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
|
|
|
3167
3232
|
else:
|
|
3168
3233
|
headers = {"Authorization": "Bearer " + use_key}
|
|
3169
3234
|
body = {"model": model_name, "max_tokens": max_tokens,
|
|
3170
|
-
"messages": [{"role": "user",
|
|
3235
|
+
"messages": [{"role": "user",
|
|
3236
|
+
"content": _chat_user_content(proto, prompt, images)}]}
|
|
3171
3237
|
effort = str(reasoning_effort or "").strip().lower()
|
|
3172
3238
|
if proto == "openai" and effort in ("low", "medium", "high"):
|
|
3173
3239
|
body["reasoning_effort"] = effort
|
|
@@ -3255,7 +3321,7 @@ def chat(provider_id, model_name, prompt, max_tokens=2048, timeout=120, cache_tt
|
|
|
3255
3321
|
if cache_ttl > 0:
|
|
3256
3322
|
try:
|
|
3257
3323
|
cache_path = _chat_cache_path(provider_id, model_name, prompt, max_tokens,
|
|
3258
|
-
reasoning_effort)
|
|
3324
|
+
reasoning_effort, images)
|
|
3259
3325
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
3260
3326
|
tmp = cache_path.with_suffix(".tmp")
|
|
3261
3327
|
import json as _json
|
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:
|
|
@@ -1541,13 +1552,27 @@ def _run_code(run, task, agents, ev, stats, mode):
|
|
|
1541
1552
|
_write_project_memory(task, workdir, _mem_lines)
|
|
1542
1553
|
except Exception:
|
|
1543
1554
|
pass
|
|
1555
|
+
# Stop gate(planning-with-files):收工时活计划若有未勾选项,摘要与 verdict
|
|
1556
|
+
# 如实点名——「计划没走完就报完成」属于静默降级(赛马/异常路径不回写勾选时
|
|
1557
|
+
# 此前无任何可见信号)。只保可见不改判:勾选是文件事实,质量判据仍是评审。
|
|
1558
|
+
try:
|
|
1559
|
+
gate_total, gate_left = plan_stopgate(workdir)
|
|
1560
|
+
except Exception:
|
|
1561
|
+
gate_total, gate_left = 0, []
|
|
1562
|
+
gate_note = ""
|
|
1563
|
+
if gate_left:
|
|
1564
|
+
verdict["plan_gate"] = "活计划 %d 项中 %d 项未勾选完成:%s" % (
|
|
1565
|
+
gate_total, len(gate_left),
|
|
1566
|
+
"、".join("#%d[%s]%s" % (n, mk, t) for n, mk, t in gate_left[:3]))
|
|
1567
|
+
gate_note = ";⚠ 活计划 %d/%d 项完成" % (gate_total - len(gate_left), gate_total)
|
|
1544
1568
|
store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
|
|
1545
|
-
summary="代码任务%s(验证%s / 评审%s%s)" % (
|
|
1569
|
+
summary="代码任务%s(验证%s / 评审%s%s%s)" % (
|
|
1546
1570
|
"通过" if overall_pass else "未通过",
|
|
1547
1571
|
"通过" if verify_pass else "未通过",
|
|
1548
1572
|
(("通过" if review_json.get("pass") else "未通过")
|
|
1549
1573
|
if workflow["review_required"] else "按策略省略"),
|
|
1550
|
-
",%d 轮修复" % (len(repairs) - 1) if len(repairs) > 1 else ""
|
|
1574
|
+
",%d 轮修复" % (len(repairs) - 1) if len(repairs) > 1 else "",
|
|
1575
|
+
gate_note),
|
|
1551
1576
|
ended_at=_now())
|
|
1552
1577
|
|
|
1553
1578
|
|
|
@@ -4145,6 +4170,31 @@ def _write_task_plan(task, workdir, plan):
|
|
|
4145
4170
|
_PLAN_MARK = {"done": "x", "fail": "!", "run": ">"}
|
|
4146
4171
|
|
|
4147
4172
|
|
|
4173
|
+
def plan_stopgate(workdir):
|
|
4174
|
+
"""Stop gate(planning-with-files 第四件):收工前校验活计划勾选完整性。
|
|
4175
|
+
返回 (总项数, [(序号, 标记, 标题)]);计划文件缺失/异常 → (0, [])(无计划不拦)。
|
|
4176
|
+
未完成 = 勾选框不是 [x](含 [ ] 未跑、[!] 失败、[>] 中断在跑)。"""
|
|
4177
|
+
try:
|
|
4178
|
+
from pathlib import Path as _P
|
|
4179
|
+
import re as _re
|
|
4180
|
+
root = _P(workdir).resolve()
|
|
4181
|
+
target = (root / ".codebee" / "task_plan.md").resolve()
|
|
4182
|
+
if root not in target.parents or not target.is_file():
|
|
4183
|
+
return 0, []
|
|
4184
|
+
pat = _re.compile(r"^(\d+)\. \[([ x!>])\] ")
|
|
4185
|
+
total, unfinished = 0, []
|
|
4186
|
+
for ln in target.read_text("utf-8").splitlines():
|
|
4187
|
+
m = pat.match(ln)
|
|
4188
|
+
if not m:
|
|
4189
|
+
continue
|
|
4190
|
+
total += 1
|
|
4191
|
+
if m.group(2) != "x":
|
|
4192
|
+
unfinished.append((int(m.group(1)), m.group(2), ln[m.end():][:60]))
|
|
4193
|
+
return total, unfinished
|
|
4194
|
+
except Exception:
|
|
4195
|
+
return 0, []
|
|
4196
|
+
|
|
4197
|
+
|
|
4148
4198
|
def mark_task_plan(workdir, index, status):
|
|
4149
4199
|
"""活计划回写:把 .codebee/task_plan.md 第 index 项(1 基)的勾选框
|
|
4150
4200
|
翻成 [x](完成)/[!](失败)/[>](进行中)。planning-with-files 的断点
|
|
@@ -4211,7 +4261,7 @@ def execute_run(run_id):
|
|
|
4211
4261
|
if initial_status not in ("queued", "running"):
|
|
4212
4262
|
return
|
|
4213
4263
|
if store.update_run(run_id, expected_status=initial_status, status="running",
|
|
4214
|
-
started_at=_now()) is None:
|
|
4264
|
+
started_at=_now(), resume_enqueue_at="", warnings=[]) is None:
|
|
4215
4265
|
return
|
|
4216
4266
|
if task is None:
|
|
4217
4267
|
store.update_run(run_id, expected_status="running", status="failed",
|
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
|
@@ -981,7 +981,7 @@ def _model_flag(kind, model):
|
|
|
981
981
|
return ["--model", model] # claude / opencode / aider
|
|
982
982
|
|
|
983
983
|
|
|
984
|
-
|
|
984
|
+
_TRANSIENT_DEFAULT = ("503", "502", "529", "429", "no available channel", "temporarily",
|
|
985
985
|
"unavailable", "overloaded", "rate limit", "timeout", "timed out",
|
|
986
986
|
"输出停滞",
|
|
987
987
|
# 2026-09-15 连载验收实测:网关故障形态远不止 HTTP 5xx——
|
|
@@ -1007,10 +1007,15 @@ def _model_flag(kind, model):
|
|
|
1007
1007
|
"enotfound", "getaddrinfo", "name or service not known",
|
|
1008
1008
|
"name resolution")
|
|
1009
1009
|
|
|
1010
|
+
# 保留兼容别名:旧 worker 热重载时可能短暂丢失模块级变量,判断函数不能因此
|
|
1011
|
+
# 把一次普通上游故障升级成 NameError。
|
|
1012
|
+
_TRANSIENT = _TRANSIENT_DEFAULT
|
|
1010
1013
|
|
|
1011
|
-
|
|
1014
|
+
|
|
1015
|
+
def _transient_error(err, _default_markers=_TRANSIENT_DEFAULT):
|
|
1012
1016
|
err = (err or "").lower()
|
|
1013
|
-
|
|
1017
|
+
markers = globals().get("_TRANSIENT") or _default_markers
|
|
1018
|
+
return any(k in err for k in markers)
|
|
1014
1019
|
|
|
1015
1020
|
|
|
1016
1021
|
# 欠费/配额/限流耗尽:换 KEY 与换厂商都该继续(同厂商另一账号往往还能用)。
|
|
@@ -1038,6 +1043,17 @@ def _auth_error(err):
|
|
|
1038
1043
|
return any(k in err for k in _AUTH)
|
|
1039
1044
|
|
|
1040
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
|
+
|
|
1041
1057
|
def _attempt_credential(att):
|
|
1042
1058
|
"""绑定链中凭据的非敏感身份;不把密钥值写入日志或尝试记录。"""
|
|
1043
1059
|
return (str((att or {}).get("provider_id") or ""),
|
|
@@ -1616,6 +1632,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1616
1632
|
grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
|
|
1617
1633
|
timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
|
|
1618
1634
|
skipped_upstreams = set()
|
|
1635
|
+
forbidden_upstreams = set()
|
|
1619
1636
|
auth_failed_credentials = set()
|
|
1620
1637
|
for ai, att in enumerate(attempts):
|
|
1621
1638
|
credential = _attempt_credential(att)
|
|
@@ -1633,7 +1650,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1633
1650
|
out["attempts"] = list(attempt_history)
|
|
1634
1651
|
continue
|
|
1635
1652
|
upstream = _attempt_upstream(att)
|
|
1636
|
-
if upstream in skipped_upstreams:
|
|
1653
|
+
if upstream in skipped_upstreams or upstream in forbidden_upstreams:
|
|
1637
1654
|
continue
|
|
1638
1655
|
# 有任务 deadline 时,所有候选共享同一个截止时刻;否则每个候选
|
|
1639
1656
|
# 使用 catalog 配置或 caller 提供的单模型时限。
|
|
@@ -1758,7 +1775,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1758
1775
|
except Exception:
|
|
1759
1776
|
pass
|
|
1760
1777
|
if not (_quota_error(out["error"]) or _transient_error(out["error"])
|
|
1761
|
-
or _rate_limited(out["error"])
|
|
1778
|
+
or _rate_limited(out["error"])
|
|
1779
|
+
or _permission_error(out["error"])):
|
|
1762
1780
|
# 尾段清洗后没有错误信号而流式审计日志里有:补日志错误行。
|
|
1763
1781
|
# 2026-09-22 实测 kimi 撞 429 时 stderr/stdout 只剩启动横幅
|
|
1764
1782
|
# (真错误只走了流式管道),路由层看不到配额关键词,换将
|
|
@@ -1862,6 +1880,15 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1862
1880
|
for candidate in attempts[ai + 1:]):
|
|
1863
1881
|
return out
|
|
1864
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
|
|
1865
1892
|
# 换将闸门:什么失败值得烧链上下一个候选。
|
|
1866
1893
|
# · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
|
|
1867
1894
|
# · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
|
|
@@ -1874,6 +1901,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
|
|
|
1874
1901
|
if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
|
|
1875
1902
|
or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
|
|
1876
1903
|
or _refusal_error(out.get("error"))
|
|
1904
|
+
or _permission_error(out.get("error"))
|
|
1877
1905
|
or raw.get("timed_out"))):
|
|
1878
1906
|
return out # 取消/解析失败等真终态不降级
|
|
1879
1907
|
# 同一上游的模型连续两次超时就收手——
|
package/app/core/store.py
CHANGED
|
@@ -18,7 +18,7 @@ import threading
|
|
|
18
18
|
import time
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
|
|
21
|
-
from . import paths, runner, volumes
|
|
21
|
+
from . import artifacts, paths, runner, volumes
|
|
22
22
|
|
|
23
23
|
LOCK = threading.RLock()
|
|
24
24
|
_TASKS = {}
|
|
@@ -746,7 +746,7 @@ def task_side(task_id):
|
|
|
746
746
|
total_steps = sum(len(r.get("steps") or []) for r in runs)
|
|
747
747
|
# 成品口径闸:任务一步都没跑出来过(如历次都在检出前失败)→ 工作目录里的
|
|
748
748
|
# 文件变动全是并行活动的噪音,不算这个任务的成品
|
|
749
|
-
wd, arts = run_artifacts(latest["id"], limit=50) if (latest and total_steps > 0) else ("", [])
|
|
749
|
+
wd, arts = run_artifacts(latest["id"], limit=50, cache=True) if (latest and total_steps > 0) else ("", [])
|
|
750
750
|
return {
|
|
751
751
|
"task": {k: task.get(k) for k in ("id", "title", "status", "workdir", "git_state",
|
|
752
752
|
"git_rev")},
|
|
@@ -966,41 +966,6 @@ def run_workdir(run_id):
|
|
|
966
966
|
return str(task.get("workdir") or "") if task else ""
|
|
967
967
|
|
|
968
968
|
|
|
969
|
-
_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", ".idea", ".vscode"}
|
|
970
|
-
|
|
971
|
-
# 构建产物/依赖缓存目录:里面的文件是工具链再生成的,不是任务成品
|
|
972
|
-
_BUILD_DIRS = {
|
|
973
|
-
"target", "build", "dist", "out", "bin", "obj", # 通用构建输出
|
|
974
|
-
"surefire", "failsafe-reports", "test-output", "reports", # 测试/报告输出
|
|
975
|
-
".next", ".nuxt", ".output", ".gradle", ".gradle-home", # 前端/Gradle
|
|
976
|
-
"__MACOSX",
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
# CLI 自身的历史/评审中间件不属于用户成果。它们通常落在工作目录根部,
|
|
980
|
-
# 只按隐藏目录过滤会漏掉 Aider 的 .aider.* 与流程生成的 *_review.json。
|
|
981
|
-
_PROCESS_ARTIFACT_NAMES = {
|
|
982
|
-
".aider.chat.history.md", ".aider.input.history", ".aider.input.history.md",
|
|
983
|
-
".aider.tags.cache.v4", ".aider.tags.cache.v3", ".aider.conf.yml",
|
|
984
|
-
}
|
|
985
|
-
_PROCESS_ARTIFACT_SUFFIXES = ("_review.json", ".review.json")
|
|
986
|
-
_PROCESS_ARTIFACT_PREFIXES = ("tutti_prompt_", "_tutti_prompt_")
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
def _is_process_artifact(rel_name):
|
|
990
|
-
"""判断工作目录文件是否为 CLI/评审过程产物,而非可交付成果。"""
|
|
991
|
-
name = str(rel_name or "").replace("\\", "/")
|
|
992
|
-
base = name.rsplit("/", 1)[-1].lower()
|
|
993
|
-
if base in _PROCESS_ARTIFACT_NAMES:
|
|
994
|
-
return True
|
|
995
|
-
if base.startswith(".") and base.startswith(".aider"):
|
|
996
|
-
return True
|
|
997
|
-
if base.endswith(_PROCESS_ARTIFACT_SUFFIXES):
|
|
998
|
-
return True
|
|
999
|
-
if base.startswith(_PROCESS_ARTIFACT_PREFIXES):
|
|
1000
|
-
return True
|
|
1001
|
-
return False
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
969
|
def task_first_start(task_id, fallback=""):
|
|
1005
970
|
"""该任务最早一次运行的开始时间(含回退:任务创建时间 → 指定回退值)。"""
|
|
1006
971
|
stamps = []
|
|
@@ -1019,13 +984,15 @@ def task_first_start(task_id, fallback=""):
|
|
|
1019
984
|
return best
|
|
1020
985
|
|
|
1021
986
|
|
|
1022
|
-
def run_artifacts(run_id, limit=200):
|
|
987
|
+
def run_artifacts(run_id, limit=200, cache=False):
|
|
1023
988
|
"""列一次运行的「成品文件」:工作目录里自该任务首次运行以来新产生/修改的文件。
|
|
1024
989
|
|
|
1025
990
|
断点续跑会拆成多条 run,只按本 run 过滤会漏掉早期章节;这里以「任务首跑」
|
|
1026
991
|
为起点。返回 (workdir, files);files 按 mtime 新→旧,name 为工作目录内
|
|
1027
|
-
相对路径,已跳过 .git / 隐藏目录 / node_modules / target
|
|
1028
|
-
|
|
992
|
+
相对路径,已跳过 .git / 隐藏目录 / node_modules / target 等构建产物目录。
|
|
993
|
+
cache=True 供 UI 轮询热路径(详情/侧栏/状态快照)复用短 TTL 缓存与在飞
|
|
994
|
+
合并;发布/预览/知识库等正确性敏感方保持默认,永远现扫。
|
|
995
|
+
剪枝遍历与过滤实现在 artifacts.py(几万文件目录从秒级降到百毫秒级)。
|
|
1029
996
|
"""
|
|
1030
997
|
wd = run_workdir(run_id)
|
|
1031
998
|
if not wd:
|
|
@@ -1040,36 +1007,12 @@ def run_artifacts(run_id, limit=200):
|
|
|
1040
1007
|
root = Path(wd)
|
|
1041
1008
|
if not root.is_dir():
|
|
1042
1009
|
return wd, []
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
continue
|
|
1050
|
-
if any(part in _BUILD_DIRS for part in p.parts[:-1]):
|
|
1051
|
-
continue
|
|
1052
|
-
# 隐藏目录一律是工具过程文件(.mimocode/.zcode/.claude/.codex…),
|
|
1053
|
-
# 不是成品;按前缀通排,免得每来一个新 agent CLI 就补一次白名单
|
|
1054
|
-
if any(part.startswith(".") for part in p.parts[:-1]):
|
|
1055
|
-
continue
|
|
1056
|
-
try:
|
|
1057
|
-
st = p.stat()
|
|
1058
|
-
except OSError:
|
|
1059
|
-
continue
|
|
1060
|
-
if st.st_mtime < t0:
|
|
1061
|
-
continue
|
|
1062
|
-
rel_name = str(p.relative_to(root)).replace("\\", "/")
|
|
1063
|
-
if _is_process_artifact(rel_name):
|
|
1064
|
-
continue
|
|
1065
|
-
files.append({"name": rel_name,
|
|
1066
|
-
"size": st.st_size, "mtime": int(st.st_mtime)})
|
|
1067
|
-
if len(files) >= 800: # 防超大目录拖垮接口;截断后再排序取最新
|
|
1068
|
-
break
|
|
1069
|
-
except OSError:
|
|
1070
|
-
pass
|
|
1071
|
-
files.sort(key=lambda f: -f["mtime"])
|
|
1072
|
-
return wd, files[:limit]
|
|
1010
|
+
running = False
|
|
1011
|
+
if cache:
|
|
1012
|
+
task = get_task(run.get("task_id") or "") if run.get("task_id") else None
|
|
1013
|
+
running = bool(task) and task.get("status") in ("queued", "running")
|
|
1014
|
+
return wd, artifacts.scan(str(root), t0, limit=limit,
|
|
1015
|
+
running=running, cache=cache)
|
|
1073
1016
|
|
|
1074
1017
|
|
|
1075
1018
|
def task_step_count(task_id):
|