codebee 0.1.4 → 0.1.5

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.
@@ -66,6 +66,11 @@ def repo_info(workdir):
66
66
  if h.strip():
67
67
  recent.append({"hash": h.strip(), "subject": subj.strip()[:120]})
68
68
  br = (branch["stdout"] or "").strip() if branch["ok"] else ""
69
+ if br == "HEAD":
70
+ # 游离 HEAD 时 abbrev-ref 返回字面量 "HEAD"——统一成哨兵值。
71
+ # 否则「切回 from_branch」会把 "HEAD" 当分支名原样 checkout,
72
+ # 而 git checkout HEAD 是原地空转(退出码 0 但不换位),分支永远切不走。
73
+ br = "(游离 HEAD)"
69
74
  return {
70
75
  "repo": True,
71
76
  "branch": br or "(游离 HEAD)",
@@ -408,7 +413,7 @@ def finalize_run(workdir, gitinfo, message):
408
413
  # 切回原分支;原为游离 HEAD 时回到基线提交(同样游离)
409
414
  back = (gitinfo or {}).get("from_branch") or ""
410
415
  base = (gitinfo or {}).get("base_commit") or ""
411
- target = back if (back and back != "(游离 HEAD)" and back != branch) else base
416
+ target = back if (back and back not in ("(游离 HEAD)", "HEAD") and back != branch) else base
412
417
  if target:
413
418
  co = _git(workdir, "checkout", "--quiet", target, timeout=60)
414
419
  if not co["ok"]:
@@ -472,6 +477,12 @@ def merge_task_branch(workdir, task):
472
477
  out = {"restore_error": ""}
473
478
  _restore_stash(wd, stash_sha, out)
474
479
  return refuse("无法确定任务分支的基线分支(找不到带检出信息的运行记录),请手工合并")
480
+ if origin in ("HEAD", "(游离 HEAD)"):
481
+ # 老数据里游离基线存的是字面量 "HEAD":合并没有分支可落,显式拒绝
482
+ if stash_sha:
483
+ out = {"restore_error": ""}
484
+ _restore_stash(wd, stash_sha, out)
485
+ return refuse("任务分支从游离 HEAD 检出,没有可合并的基线分支;请手工合并")
475
486
  if cur != origin:
476
487
  if stash_sha:
477
488
  out = {"restore_error": ""}
@@ -516,11 +527,56 @@ def merge_task_branch(workdir, task):
516
527
  return True, "", info_out
517
528
 
518
529
 
530
+ def _worktrees_on_branch(workdir, br):
531
+ """git worktree list --porcelain → 检出 br 的工作树路径列表。
532
+
533
+ porcelain 自 git 2.7 起可用;解析失败返回 None(调用方显式拒绝,
534
+ 绝不带着「分支可能仍被占用」硬删)。
535
+ """
536
+ w = _git(workdir, "worktree", "list", "--porcelain")
537
+ if not w["ok"]:
538
+ return None
539
+ entries, cur = [], None
540
+ for ln in (w["stdout"] or "").splitlines():
541
+ if ln.startswith("worktree "):
542
+ cur = {"path": ln[len("worktree "):].strip(), "branch": ""}
543
+ entries.append(cur)
544
+ elif cur is not None and ln.startswith("branch refs/heads/"):
545
+ cur["branch"] = ln[len("branch refs/heads/"):].strip()
546
+ return [x for x in entries if x["branch"] == br]
547
+
548
+
549
+ def _switch_off_branch(workdir, br, target):
550
+ """把工作树从 br 上切走,返回错误信息(成功为空串)。
551
+
552
+ 先尝试切到 target;两种失败形态都兜底成游离:
553
+ - checkout 直接失败(如目标分支被别的工作树占用)→ --detach target;
554
+ - checkout「成功」却没换位(target 是 HEAD 这类原地引用,实测
555
+ git checkout HEAD 退出码 0 且停在原分支)→ 复查后强制游离。
556
+ 最终以 rev-parse 复核 br 真的腾空为准,不信 checkout 的退出码。
557
+ """
558
+ co = _git(workdir, "checkout", "--quiet", target, timeout=60) if target \
559
+ else _git(workdir, "checkout", "--quiet", "--detach", timeout=60)
560
+ cur = _git(workdir, "rev-parse", "--abbrev-ref", "HEAD")
561
+ if (cur["stdout"] or "").strip() == br:
562
+ co = _git(workdir, "checkout", "--quiet", "--detach", target, timeout=60) \
563
+ if target else _git(workdir, "checkout", "--quiet", "--detach", timeout=60)
564
+ if not co["ok"]:
565
+ return "切离任务分支失败:%s" % (co["stderr"] or "")[-120:]
566
+ elif not co["ok"]:
567
+ return "切回 %s 失败:%s" % (target, (co["stderr"] or "")[-120:])
568
+ return ""
569
+
570
+
519
571
  def discard_task_branch(workdir, task):
520
572
  """丢弃任务分支(人审后的「否决」出口,不可恢复)。
521
573
 
522
574
  守卫同 merge(活跃任务/仓库缺失/分支不存在拒绝);若工作区恰好停在
523
575
  任务分支上且干净(收尾出错的遗留现场),先切回基线再删分支。
576
+ 分支也可能被**其它** worktree 检出(run 收尾被中断、任务分支滞留在
577
+ 另一个工作树里)——git 拒绝删除被检出的分支,所以删前逐个清理:
578
+ 干净的工作树切回基线(目标分支被占用就游离在那),有未提交改动的
579
+ 不动、显式拒绝;目录已消失的残留登记由 worktree prune 清掉。
524
580
  返回 (ok, 错误信息)。
525
581
  """
526
582
  def refuse(msg):
@@ -536,19 +592,37 @@ def discard_task_branch(workdir, task):
536
592
  br = branch_name(tid)
537
593
  if not _git(wd, "rev-parse", "--verify", "--quiet", "refs/heads/" + br)["ok"]:
538
594
  return refuse("任务分支 %s 不存在(可能已被合并或丢弃)" % br)
595
+ latest = _latest_run_git(tid) or {}
596
+ back = latest.get("from_branch") or ""
597
+ base = latest.get("base_commit") or ""
598
+ target = back if (back and back not in ("(游离 HEAD)", "HEAD") and back != br) else base
539
599
  cur = info.get("branch") or ""
540
600
  if cur == br:
541
601
  if info.get("dirty"):
542
602
  return refuse("工作区停在任务分支上且有未提交改动,请先处理再丢弃")
543
- latest = _latest_run_git(tid) or {}
544
- back = latest.get("from_branch") or ""
545
- base = latest.get("base_commit") or ""
546
- target = back if (back and back != "(游离 HEAD)" and back != br) else base
547
603
  if not target:
548
604
  return refuse("工作区停在任务分支上且找不到可切回的基线,请手工切走后再丢弃")
549
- co = _git(wd, "checkout", "--quiet", target, timeout=60)
550
- if not co["ok"]:
551
- return refuse("切回 %s 失败:%s" % (target, (co["stderr"] or "")[-160:]))
605
+ err = _switch_off_branch(wd, br, target)
606
+ if err:
607
+ return refuse(err)
608
+ wts = _worktrees_on_branch(wd, br)
609
+ if wts is None:
610
+ return refuse("无法枚举 worktree,请手工处理占用任务分支的工作树后再丢弃")
611
+ for wt in wts:
612
+ p = wt["path"]
613
+ if not os.path.isdir(p):
614
+ continue
615
+ st = _git(p, "status", "--porcelain")
616
+ dirty = [ln for ln in (st["stdout"] or "").splitlines()
617
+ if ln.strip() and not _attach_entry(ln.strip())] if st["ok"] else []
618
+ if dirty:
619
+ return refuse("任务分支仍被工作树 %s 占用且其中有未提交改动,"
620
+ "请先处理后再丢弃" % p)
621
+ err = _switch_off_branch(p, br, target)
622
+ if err:
623
+ return refuse("工作树 %s %s" % (p, err))
624
+ if wts:
625
+ _git(wd, "worktree", "prune")
552
626
  d = _git(wd, "branch", "-D", br)
553
627
  if not d["ok"]:
554
628
  return refuse("删除任务分支失败:%s" % (d["stderr"] or "")[-160:])
@@ -18,14 +18,20 @@ def agent_stats(limit=200):
18
18
  s["wins"] += 1
19
19
 
20
20
  for run in store.list_runs(limit):
21
- if run.get("kind") != "orchestration" or run.get("status") != "done":
21
+ if run.get("kind") != "orchestration":
22
+ continue
23
+ # 失败运行也要进历史(记为全负):以前只数 done——常挂的 CLI 永远
24
+ # 「无历史记录」满血参选,2026-09-17 opencode 当日秒挂 3 次仍被换将
25
+ # 选中。cancelled 不算(用户取消非智能体之过)。
26
+ if run.get("status") not in ("done", "failed"):
22
27
  continue
23
28
  verdict = run.get("verdict") or {}
24
29
  task = store.get_task(run.get("task_id")) if run.get("task_id") else None
25
30
  ttype = (task or {}).get("type") or verdict.get("type")
26
31
  if not ttype:
27
32
  continue
28
- win = bool(verdict.get("pass") or verdict.get("publishable"))
33
+ win = run.get("status") == "done" and bool(
34
+ verdict.get("pass") or verdict.get("publishable"))
29
35
  for s in run.get("steps") or []:
30
36
  if s.get("role") in ("implement", "draft"):
31
37
  bump(s.get("agent"), ttype, win)
package/app/core/jobs.py CHANGED
@@ -205,6 +205,9 @@ def resume_interrupted(limit=3):
205
205
  continue
206
206
  if run.get("cancelled_by_user"):
207
207
  continue
208
+ if run.get("paused"):
209
+ continue # 用户主动暂停的运行(重启收尸后 paused 标志保留)不自动续跑——
210
+ # 「继续」由用户点「继续任务」决定,不替用户做主
208
211
  if not _recent(run):
209
212
  continue
210
213
  if latest_by_task.get(run["task_id"]) != run["id"]:
@@ -320,8 +323,12 @@ def _do_mgmt(job, ev):
320
323
  store.finish_step(run_id, step["n"], "failed", summary="该智能体未安装或未启用编排")
321
324
  store.update_run(run_id, status="failed", error="未启用", ended_at=_now())
322
325
  return
323
- res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK", readonly=True,
324
- timeout=180, cancel_event=ev, log_path=str(log_abs))
326
+ res = _r.run_agent(agent, "连通性测试:请只回复两个字:OK",
327
+ readonly=True,
328
+ # 300s:codex CLI 启动要拉 5 个 MCP 服务器 + 注入约 13 万
329
+ # token 技能上下文,首 token 常超 180s(2026-09-17 实测
330
+ # 网关裸探 8s 就回,慢在 CLI 自身启动与上下文)。
331
+ timeout=300, cancel_event=ev, log_path=str(log_abs))
325
332
  ok = res["ok"] and "OK" in (res.get("text") or "").upper()
326
333
  try:
327
334
  _usage.record(source="smoke", run_id=run_id, step=step["n"], role="smoke",
@@ -69,6 +69,52 @@ def detect_entry(entry):
69
69
  return {"installed": False, "detail": ""}
70
70
 
71
71
 
72
+ def sweep_orphan_cli_processes():
73
+ """启动清扫:服务重启会孤儿化正在跑的 CLI 孙进程(外部只杀服务 PID,不带
74
+ /T),僵尸 opencode 更会劫持后续会话——opencode 是客户端-服务端架构,新
75
+ `opencode run` 连上僵尸实例后 shell 全在僵尸的项目根里跑(2026-09-17
76
+ mo-so 实测:agent 在 Temp 里找代码,汇报「工作目录没有源码」)。
77
+
78
+ 按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
79
+ opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
80
+ codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
81
+ claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
82
+ ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
83
+ "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
84
+ if not os.path.isfile(ps_exe):
85
+ ps_exe = "powershell"
86
+ ps = ("Get-CimInstance Win32_Process | "
87
+ "Where-Object { $_.Name -match '^(opencode|codex|node|cmd)\\.exe$' } | "
88
+ "Select-Object ProcessId,ParentProcessId,CommandLine | ConvertTo-Json -Compress")
89
+ try:
90
+ r = subprocess.run([ps_exe, "-NoProfile", "-Command", ps],
91
+ capture_output=True, creationflags=CREATE_NO_WINDOW, timeout=60)
92
+ import json as _json
93
+ raw = r.stdout.decode("utf-8", "replace").strip()
94
+ items = _json.loads(raw) if raw else []
95
+ if isinstance(items, dict):
96
+ items = [items]
97
+ live = {i.get("ProcessId") for i in items}
98
+ killed = 0
99
+ for i in items:
100
+ cl = str(i.get("CommandLine") or "").lower()
101
+ ppid = i.get("ParentProcessId")
102
+ is_ours = (("opencode" in cl and "--model" in cl)
103
+ or ("codex" in cl and "--skip-git-repo-check" in cl))
104
+ if not is_ours or ppid in live or not i.get("ProcessId"):
105
+ continue
106
+ try:
107
+ subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
108
+ capture_output=True, creationflags=CREATE_NO_WINDOW,
109
+ timeout=15)
110
+ killed += 1
111
+ except Exception:
112
+ pass
113
+ return killed
114
+ except Exception:
115
+ return 0
116
+
117
+
72
118
  def detect_all(force=False):
73
119
  """检测全部条目。检测(慢磁盘 IO)在锁外跑:shutil.which/isfile 在
74
120
  Windows 上遇到断链的 PATH 项可能卡数秒,持锁会把所有并发请求堵死
@@ -1091,10 +1137,18 @@ def _sync_opencode_settings(entry, model, prov):
1091
1137
  全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
1092
1138
  文本级就地 patch(保留注释)。返回错误串或 None。"""
1093
1139
  npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
1140
+ base = prov.get("base_url") or ""
1141
+ if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
1142
+ # @ai-sdk/anthropic 在 baseURL 后只拼 /messages(官方默认 baseURL 本身带
1143
+ # /v1),而 models.json 里 anthropic 供应商的 base 不带 /v1(modelhub
1144
+ # 发请求时自己补)——不补会打到 <host>/messages,网关回 "Not Allowed"
1145
+ # (2026-09-17 公司Anthropic 实测)。
1146
+ base = base.rstrip("/") + "/v1"
1094
1147
  block = {"npm": npm, "name": prov.get("name") or "CodeBee 绑定",
1095
- "options": {"baseURL": prov.get("base_url") or "",
1148
+ "options": {"baseURL": base,
1096
1149
  "apiKey": prov.get("api_key") or ""},
1097
1150
  "models": {model: {"name": model}} if model else {}}
1151
+ top_perms = {"edit": "allow", "bash": "allow", "webfetch": "allow"}
1098
1152
  top_model = ("orch/" + model) if model else ""
1099
1153
  targets = [p for p in _opencode_config_candidates(entry) if os.path.isfile(p)] \
1100
1154
  or [_opencode_config_candidates(entry)[0]]
@@ -1130,6 +1184,10 @@ def _sync_opencode_settings(entry, model, prov):
1130
1184
  data["provider"] = provs
1131
1185
  if top_model:
1132
1186
  data["model"] = top_model
1187
+ # 无人值守必配:headless 下 opencode 工具调用默认要审批,全部被
1188
+ # 拒("The user rejected permission...",2026-09-17 实测)——
1189
+ # 按用户拍板的「默认给全部权限」写入放行段
1190
+ data["permission"] = top_perms
1133
1191
  new_text = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
1134
1192
  else: # JSONC(带注释):文本级 patch
1135
1193
  block_json = json.dumps(block, ensure_ascii=False)
@@ -1140,6 +1198,8 @@ def _sync_opencode_settings(entry, model, prov):
1140
1198
  if top_model:
1141
1199
  new_text, _ = _jsonc_set(new_text, ("model",),
1142
1200
  json.dumps(top_model))
1201
+ new_text, _ = _jsonc_set(new_text, ("permission",),
1202
+ json.dumps(top_perms))
1143
1203
  if os.path.isfile(path):
1144
1204
  shutil.copyfile(path, path + ".bak")
1145
1205
  Path(path).write_bytes(new_text.encode("utf-8"))
@@ -1148,12 +1208,102 @@ def _sync_opencode_settings(entry, model, prov):
1148
1208
  return ";".join(errs) or None
1149
1209
 
1150
1210
 
1211
+ def _kimi_render(prov, model):
1212
+ """渲染 kimi-code 的 CodeBee 托管块(顶层键在前,表在后——TOML 语义)。"""
1213
+ import re as _re
1214
+ ptype = "anthropic" if prov.get("protocol") == "anthropic" else "openai"
1215
+ base = prov.get("base_url") or ""
1216
+ alias = model or "default"
1217
+ pname = (prov.get("name") or "CodeBee").replace("\"", "")
1218
+ return (
1219
+ "# >>> CodeBee managed (do not edit between markers) >>>\n"
1220
+ "defaultProvider = \"orch\"\n"
1221
+ "defaultModel = \"%s\"\n"
1222
+ "yolo = true\n"
1223
+ "defaultPermissionMode = \"yolo\"\n"
1224
+ "\n"
1225
+ "[providers.orch]\n"
1226
+ "type = \"%s\"\n"
1227
+ "name = \"%s\"\n"
1228
+ "baseUrl = \"%s\"\n"
1229
+ "apiKey = \"%s\"\n"
1230
+ "\n"
1231
+ "[models.\"%s\"]\n"
1232
+ "provider = \"orch\"\n"
1233
+ "model = \"%s\"\n"
1234
+ "maxContextSize = 131072\n"
1235
+ "displayName = \"%s · %s\"\n"
1236
+ "# <<< CodeBee managed <<<\n"
1237
+ % (alias, ptype, pname, base,
1238
+ (prov.get("api_key") or "").replace("\"", ""),
1239
+ alias, alias, pname, alias))
1240
+
1241
+
1242
+ def _sync_kimi_settings(entry, model, prov):
1243
+ """kimi-code 专属:把绑定供应商写进 ~/.kimi-code/config.toml。
1244
+
1245
+ schema 从官方 bundle 反推(2026-09-17):顶层 defaultProvider/defaultModel/
1246
+ yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
1247
+ baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
1248
+ 要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
1249
+ 之间)幂等重写,用户自有内容保留在外。"""
1250
+ import re as _re
1251
+ top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
1252
+ targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
1253
+ errs = []
1254
+ for path in targets:
1255
+ try:
1256
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
1257
+ text = ""
1258
+ if os.path.isfile(path):
1259
+ text = open(path, encoding="utf-8", errors="replace", newline="").read()
1260
+ shutil.copyfile(path, path + ".bak")
1261
+ # 移除旧托管块与旧顶层键(防重复/防键落进别的表)
1262
+ text = _re.sub(r"# >>> CodeBee managed.*?# <<< CodeBee managed <<<\n?",
1263
+ "", text, flags=_re.S)
1264
+ for k in top_keys:
1265
+ text = _re.sub(r"(?m)^%s\s*=.*$\n?" % k, "", text)
1266
+ body = _re.sub(r"\n+$", "\n", text).lstrip("\n")
1267
+ managed = _kimi_render(prov, model)
1268
+ has_table = _re.search(r"(?m)^\[", body) is not None
1269
+ top = "".join("%s\n" % ln for ln in managed.split("\n")
1270
+ if _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
1271
+ tables = "\n".join(ln for ln in managed.split("\n")
1272
+ if not _re.match(r"^(%s)\s*=" % "|".join(top_keys), ln))
1273
+ if has_table:
1274
+ new_text = top + "\n" + body + "\n" + tables
1275
+ else:
1276
+ new_text = (body + "\n" if body else "") + managed
1277
+ Path(path).write_bytes(new_text.encode("utf-8"))
1278
+ except Exception as e:
1279
+ errs.append("%s: %r" % (path, e))
1280
+ return ";".join(errs) or None
1281
+
1282
+
1283
+ def sync_cli_config_now(agent_id):
1284
+ """运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
1285
+ 绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
1286
+ try:
1287
+ from . import modelhub
1288
+ entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
1289
+ if not entry:
1290
+ return ""
1291
+ b = modelhub.bindings().get(agent_id) or {}
1292
+ note = _sync_agent_injection(entry, b)
1293
+ if note:
1294
+ return "已自动注入 %s 配置:%s" % (agent_id, note)
1295
+ return ""
1296
+ except Exception as e:
1297
+ return "自动注入失败: %r" % e
1298
+
1299
+
1151
1300
  # 打开前专属注入通道:{agent_id: (可注入协议, 注入器)}。交互 TUI 脱离编排链路,
1152
1301
  # 只认自家配置文件里的凭据,编排降级给的 env(ORCH_API_KEY 等)对它们无效。
1153
1302
  _AGENT_INJECTORS = {
1154
1303
  "claude-code": (("anthropic",), _sync_claude_settings),
1155
1304
  "opencode": (("anthropic", "openai"), _sync_opencode_settings),
1156
1305
  "qwencode": (("openai",), _sync_qwen_settings),
1306
+ "kimi-code": (("openai", "anthropic"), _sync_kimi_settings),
1157
1307
  }
1158
1308
 
1159
1309
  # 无专属注入通道的专有协议 CLI:env 注入大概率无效,打开时明确告知而非静默废
@@ -114,6 +114,22 @@ def _normalize_ids(data):
114
114
  _LITE = ("mini", "flash", "lite", "nano", "small", "tiny", "8b", "7b", "4b")
115
115
  _HEAVY = ("opus", "pro", "max", "ultra", "plus", "heavy", "codex")
116
116
 
117
+ # 图片输入命名惯例:vision/omni 词段、glm-4v 式「数字+v」段;vl 子串另判
118
+ # (qwen-vl / internvl2 / cogvlm 等视觉家族的通用命名根,文本模型名含 vl 的极罕见)
119
+ _IMAGE_IN_RE = re.compile(
120
+ r"(?:^|[-_.])(?:vision|visual|omni)(?:[-_.0-9]|$)|[-_.]\dv(?:[-_.]|$)")
121
+
122
+
123
+ def _auto_image_in(name):
124
+ """按模型名启发式预填「支持图片输入」。claude/gemini 全系原生多模态直接标;
125
+ 其余只认显式命名(vision 词段、vl 子串、数字+v),宁缺勿滥——漏标的明模型
126
+ 用户手动打开即可,误标只是运行时网关显式报错、开关改回即恢复。
127
+ 仅用于新模型的初始声明。"""
128
+ n = (name or "").lower()
129
+ if n.startswith(("claude", "gemini")):
130
+ return True
131
+ return bool(_IMAGE_IN_RE.search(n)) or "vl" in n
132
+
117
133
 
118
134
  def _auto_priority(name):
119
135
  """按模型名启发式估强弱:分越高越强(优先级越靠前)。仅用于新模型的初始排序。"""
@@ -251,11 +267,14 @@ def refresh_models(provider_id):
251
267
  # 保留 hidden:用户删掉的模型刷新时不能被重新带回
252
268
  item = {"name": n, "enabled": bool(o.get("enabled", True)),
253
269
  "priority": o.get("priority", 0),
254
- "hidden": bool(o.get("hidden"))}
270
+ "hidden": bool(o.get("hidden")),
271
+ # 模态声明同理:用户/预填设过的 image_in 刷新时不能被抹掉
272
+ "image_in": bool(o.get("image_in"))}
255
273
  (hidden if item["hidden"] else existing).append(item)
256
274
  else:
257
275
  fresh.append({"name": n, "enabled": True,
258
- "auto": _auto_priority(n)})
276
+ "auto": _auto_priority(n),
277
+ "image_in": _auto_image_in(n)})
259
278
  # 已删除但本次未返回的条目也保留,否则下次拉取会当作新模型“复活”
260
279
  hidden += [dict(o) for n, o in old.items()
261
280
  if o.get("hidden") and n not in names]
@@ -729,15 +748,48 @@ def note_key_ok(provider_id, key_id):
729
748
  with _LOCK:
730
749
  data = _load()
731
750
  prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
732
- if not prov or not isinstance(prov.get("keys"), list):
751
+ if not prov:
733
752
  return
734
- target = next((k for k in prov["keys"] if str(k.get("id")) == str(key_id)), None)
753
+ target = next((k for k in (prov.get("keys") or []) if str(k.get("id")) == str(key_id)), None)
735
754
  if target is None or not target.get("last_error"):
736
755
  return
737
756
  target.pop("last_error", None)
738
757
  _save(data)
739
758
 
740
759
 
760
+ def _is_codex_target(target):
761
+ return (target or "").strip().lower() in ("codex-cli", "codex", "codex-code")
762
+
763
+
764
+ def note_codex_wire_dead(provider_id, minutes=30):
765
+ """codex 撞上 wire 不兼容的供应商 → 供应商级冷却(自动绕开的记账位)。
766
+
767
+ codex 0.154 起只支持 responses wire(chat 被官方移除),讯飞等只有
768
+ chat completions 的 MaaS 每次 404/启动即拒。runner 按 404+no Route
769
+ matched / wire_api no longer supported 特征自动调用本函数;链展开与
770
+ 路由绑定加分据此自动绕开,无需人工改绑定(2026-09-17 mo-so 实测)。
771
+ """
772
+ import time as _t
773
+ if not provider_id:
774
+ return
775
+ with _LOCK:
776
+ data = _load()
777
+ prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
778
+ if not prov:
779
+ return
780
+ prov["codex_wire_dead_until"] = _t.time() + max(1, int(minutes)) * 60
781
+ _save(data)
782
+
783
+
784
+ def codex_wire_blocked(prov):
785
+ """该供应商是否处于 codex wire 不兼容冷却期。"""
786
+ import time as _t
787
+ try:
788
+ return float((prov or {}).get("codex_wire_dead_until") or 0) > _t.time()
789
+ except Exception:
790
+ return False
791
+
792
+
741
793
  def _is_private_host(url):
742
794
  """主机是私网/环回 IP 字面量或 localhost 时返回 True(自动放行内网网关)。"""
743
795
  import urllib.parse
@@ -1813,6 +1865,14 @@ def _model_bindable(prov, model):
1813
1865
  return True
1814
1866
 
1815
1867
 
1868
+ def _model_image_in(prov, model):
1869
+ """模型是否声明支持图片输入:models[] 条目 image_in;缺省/查不到=False(纯文本)。"""
1870
+ for m in ((prov or {}).get("models") or []):
1871
+ if isinstance(m, dict) and m.get("name") == model:
1872
+ return bool(m.get("image_in"))
1873
+ return False
1874
+
1875
+
1816
1876
  def bind_agent(agent, difficulty="default"):
1817
1877
  """按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。"""
1818
1878
  r = resolve_binding(agent.get("id"), difficulty) or resolve_binding(agent.get("kind"), difficulty)
@@ -1958,6 +2018,8 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
1958
2018
  continue # 原生协议与适配过的 wire 都不匹配:跳过
1959
2019
  if prov.get("name") in down_set:
1960
2020
  continue # 健康监测判定 down:跳过,省掉无效等待
2021
+ if _is_codex_target(agent_kind_or_id) and codex_wire_blocked(prov):
2022
+ continue # codex 撞过该供应商 wire 不兼容(chat-only):冷却中自动绕开
1961
2023
  if model and not _model_bindable(prov, model):
1962
2024
  continue # 模型被停用/删除:该条跳过(2026-09-15 告警弹框「禁用该模型」)
1963
2025
  # 多 KEY:同一厂商按 KEY 展开成多条,顺序即调用顺序。欠费的 KEY 被
@@ -1993,6 +2055,8 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
1993
2055
  prov = provs.get(pid)
1994
2056
  if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
1995
2057
  return None
2058
+ if _is_codex_target(agent_kind_or_id) and codex_wire_blocked(prov):
2059
+ return None # codex wire 不兼容冷却中:解析为空 → 路由绑定分自动转负
1996
2060
  ep = _entry_endpoint(prov, allowed)
1997
2061
  if not ep:
1998
2062
  return None # google 只登记;dsh 只接受 OpenAI 兼容端点;未适配的不硬塞
@@ -2173,6 +2237,24 @@ def reorder_models(provider_id, ordered_names):
2173
2237
  return None
2174
2238
 
2175
2239
 
2240
+ def set_model_caps(provider_id, name, image_in):
2241
+ """声明单个模型的模态能力(当前仅 image_in 图片输入)。返回错误文案或 None。"""
2242
+ with _LOCK:
2243
+ data = _load()
2244
+ prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
2245
+ if not prov:
2246
+ return "供应商不存在"
2247
+ target = next((m for m in (prov.get("models") or [])
2248
+ if m.get("name") == name), None)
2249
+ if target is None:
2250
+ return "模型不存在: %s" % name
2251
+ image_in = bool(image_in)
2252
+ if bool(target.get("image_in")) != image_in: # 有差异才落盘
2253
+ target["image_in"] = image_in
2254
+ _save(data)
2255
+ return None
2256
+
2257
+
2176
2258
  def _post_json_http(url, headers, body, allow_private, timeout=20):
2177
2259
  """带 SSRF 防护的 POST。返回 (status, json_obj|None, err)。"""
2178
2260
  import urllib.parse