codebee 0.1.3 → 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.
@@ -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