codebee 0.1.49 → 0.1.51
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 +31 -0
- package/README.md +4 -150
- package/app/core/aiflavor.py +14 -0
- package/app/core/builtin_agent.py +567 -74
- package/app/core/jobs.py +170 -42
- package/app/core/knowledge.py +432 -405
- package/app/core/manager.py +100 -10
- package/app/core/market.py +27 -1
- package/app/core/modelhub.py +53 -5
- package/app/core/pipeline.py +193 -77
- package/app/core/planner.py +14 -5
- package/app/core/preview.py +293 -0
- package/app/core/router.py +189 -104
- package/app/core/runner.py +370 -144
- package/app/core/settings.py +11 -4
- package/app/core/skills.py +36 -12
- package/app/core/store.py +74 -2
- package/app/core/zentao.py +189 -30
- package/app/main.py +95 -7
- package/app/ui/app.js +13256 -12876
- package/app/ui/i18n.js +97 -34
- package/app/ui/index.html +55 -12
- package/app/ui/style.css +217 -26
- package/package.json +1 -1
package/app/core/manager.py
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import hashlib
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
11
12
|
import re
|
|
@@ -15,6 +16,7 @@ import signal
|
|
|
15
16
|
import socket
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
19
|
+
import tempfile
|
|
18
20
|
import threading
|
|
19
21
|
import time
|
|
20
22
|
import webbrowser
|
|
@@ -46,6 +48,31 @@ def _expand(p):
|
|
|
46
48
|
return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
|
|
47
49
|
|
|
48
50
|
|
|
51
|
+
def _under(path, root):
|
|
52
|
+
try:
|
|
53
|
+
return os.path.commonpath([os.path.realpath(path), root]) == root
|
|
54
|
+
except ValueError:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def tmp_data_no_home_write():
|
|
59
|
+
"""测试实例防毒闸:TUTTI_DATA 指向系统临时目录、且进程主目录仍是真实
|
|
60
|
+
主目录时,一切对真实 CLI 配置的写入都应拒绝。
|
|
61
|
+
|
|
62
|
+
一次性测试服务的 models.json 里全是 a.test/sk-test 夹具,忘设假 HOME 的
|
|
63
|
+
测试一旦触发 launch/自愈/autobind 同步,就会把夹具毒进真实
|
|
64
|
+
~/.claude/settings.json(2026-09-22 实案:claude 步骤全体 ENOTFOUND,
|
|
65
|
+
下一次测试又覆盖回来,形成「越测越坏」循环)。已设假 HOME 的测试不受
|
|
66
|
+
影响——假 HOME 本身就在临时目录下。
|
|
67
|
+
"""
|
|
68
|
+
td = (os.environ.get("TUTTI_DATA") or "").strip()
|
|
69
|
+
if not td:
|
|
70
|
+
return False
|
|
71
|
+
tmp = tempfile.gettempdir()
|
|
72
|
+
home = os.path.abspath(os.path.expanduser("~"))
|
|
73
|
+
return _under(td, tmp) and not _under(home, tmp)
|
|
74
|
+
|
|
75
|
+
|
|
49
76
|
def _safe_config_path(raw):
|
|
50
77
|
"""展开并校验配置路径:必须在用户主目录内(防穿越)。"""
|
|
51
78
|
if not raw:
|
|
@@ -165,21 +192,38 @@ def sweep_orphan_cli_processes():
|
|
|
165
192
|
|
|
166
193
|
|
|
167
194
|
def detect_all(force=False):
|
|
168
|
-
"""
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
"""检测全部条目。快照用途(force=False)热路径永不等检测:缓存新鲜直接
|
|
196
|
+
返回;过期返回旧快照、同时把刷新踢到后台线程(stale-while-revalidate)。
|
|
197
|
+
|
|
198
|
+
检测是慢磁盘 IO:shutil.which/isfile 在 Windows 上遇到断链的 PATH 项
|
|
199
|
+
可能卡数秒。此前「领队请求自己跑检测、其余请求 ev.wait(30) 排队」,每
|
|
200
|
+
60s 缓存周期都有一个 /api/catalog、/api/state(含 SSE 推送)一起卡数秒
|
|
201
|
+
的窗口;前端无超时轮询把浏览器 6 连接池塞满后整个界面假死(2026-09-22
|
|
202
|
+
用户实测「左栏点不动、右栏不刷新」)。冷启动首轮与 force=True(显式
|
|
203
|
+
重载/安装回调)仍同步跑拿新鲜结果。"""
|
|
172
204
|
with _LOCK:
|
|
173
205
|
if not force and _STATE["detected"] and time.time() - _STATE["detect_ts"] < 60:
|
|
174
206
|
return _STATE["detected"]
|
|
175
207
|
ev = _STATE["detect_ev"]
|
|
176
|
-
lead = ev is None #
|
|
208
|
+
lead = ev is None # 我是本次刷新的发起人
|
|
177
209
|
if lead:
|
|
178
210
|
ev = _STATE["detect_ev"] = threading.Event()
|
|
211
|
+
cold = not _STATE["detected"] # 冷启动首轮:还没有任何快照
|
|
179
212
|
if not lead:
|
|
180
|
-
|
|
213
|
+
if not cold:
|
|
214
|
+
return dict(_STATE["detected"]) # 过期快照也比卡请求强
|
|
215
|
+
ev.wait(30) # 冷启动窗口内并发首查:等首轮检测完成(一次性)
|
|
181
216
|
with _LOCK:
|
|
182
217
|
return dict(_STATE["detected"])
|
|
218
|
+
if not cold and not force:
|
|
219
|
+
threading.Thread(target=_detect_refresh, args=(ev,),
|
|
220
|
+
name="catalog-detect", daemon=True).start()
|
|
221
|
+
return dict(_STATE["detected"])
|
|
222
|
+
return _detect_refresh(ev)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _detect_refresh(ev):
|
|
226
|
+
"""跑一轮全量检测并落快照;完成后置时间戳、清在飞标记、放行等待者。"""
|
|
183
227
|
try:
|
|
184
228
|
detected = {}
|
|
185
229
|
for entry in catalog.load():
|
|
@@ -190,12 +234,12 @@ def detect_all(force=False):
|
|
|
190
234
|
if detected:
|
|
191
235
|
with _LOCK:
|
|
192
236
|
_STATE["detected"] = detected
|
|
237
|
+
return dict(_STATE["detected"])
|
|
193
238
|
finally:
|
|
194
239
|
with _LOCK:
|
|
195
240
|
_STATE["detect_ts"] = time.time()
|
|
196
241
|
_STATE["detect_ev"] = None
|
|
197
242
|
ev.set()
|
|
198
|
-
return detected
|
|
199
243
|
|
|
200
244
|
|
|
201
245
|
def _uwp_version(package_dir):
|
|
@@ -521,6 +565,9 @@ def read_model(entry):
|
|
|
521
565
|
def write_model(entry, model):
|
|
522
566
|
"""写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
|
|
523
567
|
json / json-path / jsonc / yaml-line。"""
|
|
568
|
+
if tmp_data_no_home_write():
|
|
569
|
+
return {"ok": False,
|
|
570
|
+
"error": "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"}
|
|
524
571
|
path = _config_path(entry)
|
|
525
572
|
cfg = entry.get("config") or {}
|
|
526
573
|
fmt = cfg.get("format")
|
|
@@ -750,6 +797,8 @@ def _sync_dsh_settings(entry, model, base_url):
|
|
|
750
797
|
端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
|
|
751
798
|
models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
|
|
752
799
|
返回错误串或 None。"""
|
|
800
|
+
if tmp_data_no_home_write():
|
|
801
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
753
802
|
path = _config_path(entry)
|
|
754
803
|
if not path:
|
|
755
804
|
return "dsh 配置路径无效"
|
|
@@ -891,6 +940,8 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
891
940
|
_codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
|
|
892
941
|
if _is_dead_endpoint(cp.get("base_url")):
|
|
893
942
|
return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
|
|
943
|
+
if tmp_data_no_home_write():
|
|
944
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
894
945
|
path = _config_path(entry)
|
|
895
946
|
if not path:
|
|
896
947
|
return "codex 配置路径无效"
|
|
@@ -1120,6 +1171,8 @@ def _sync_settings_env(path, updates, remove_keys=()):
|
|
|
1120
1171
|
"""把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
|
|
1121
1172
|
启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
|
|
1122
1173
|
不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
|
|
1174
|
+
if tmp_data_no_home_write():
|
|
1175
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
1123
1176
|
try:
|
|
1124
1177
|
if os.path.isfile(path):
|
|
1125
1178
|
text = _read_text(path)
|
|
@@ -1213,6 +1266,8 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1213
1266
|
没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
|
|
1214
1267
|
全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
|
|
1215
1268
|
文本级就地 patch(保留注释)。返回错误串或 None。"""
|
|
1269
|
+
if tmp_data_no_home_write():
|
|
1270
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
1216
1271
|
npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
|
|
1217
1272
|
base = prov.get("base_url") or ""
|
|
1218
1273
|
if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
|
|
@@ -1323,7 +1378,9 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1323
1378
|
yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
|
|
1324
1379
|
baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
|
|
1325
1380
|
要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
|
|
1326
|
-
|
|
1381
|
+
之间)幂等重写,用户自有内容保留在外。返回错误串或 None。"""
|
|
1382
|
+
if tmp_data_no_home_write():
|
|
1383
|
+
return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
|
|
1327
1384
|
import re as _re
|
|
1328
1385
|
top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
|
|
1329
1386
|
targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
|
|
@@ -1357,10 +1414,23 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1357
1414
|
return ";".join(errs) or None
|
|
1358
1415
|
|
|
1359
1416
|
|
|
1417
|
+
def _claude_sync_enabled():
|
|
1418
|
+
"""「直写 claude 配置」开关(settings 的 claude_config_sync,默认开)。
|
|
1419
|
+
关闭 = ~/.claude/settings.json 归用户手动管理(cc-switch 等),打开/运行前
|
|
1420
|
+
防线一律不落盘;编排步骤经 runner 一次性 --settings 注入绑定链,不受影响。"""
|
|
1421
|
+
try:
|
|
1422
|
+
from . import settings
|
|
1423
|
+
return settings.load().get("claude_config_sync", True)
|
|
1424
|
+
except Exception:
|
|
1425
|
+
return True
|
|
1426
|
+
|
|
1427
|
+
|
|
1360
1428
|
def sync_cli_config_now(agent_id):
|
|
1361
1429
|
"""运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
|
|
1362
1430
|
绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
|
|
1363
1431
|
try:
|
|
1432
|
+
if agent_id == "claude-code" and not _claude_sync_enabled():
|
|
1433
|
+
return "按设置跳过直写 ~/.claude 配置(编排步骤经一次性 --settings 注入,不受影响)"
|
|
1364
1434
|
from . import modelhub
|
|
1365
1435
|
entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
|
|
1366
1436
|
if not entry:
|
|
@@ -1397,6 +1467,8 @@ def _sync_agent_injection(entry, binding):
|
|
|
1397
1467
|
spec = _AGENT_INJECTORS.get(entry["id"])
|
|
1398
1468
|
if spec:
|
|
1399
1469
|
protocols, injector = spec
|
|
1470
|
+
if entry["id"] == "claude-code" and not _claude_sync_enabled():
|
|
1471
|
+
return "按设置跳过直写 ~/.claude 配置(交互 TUI 保持手动管理;编排步骤经一次性 --settings 注入不受影响)"
|
|
1400
1472
|
pick, note = modelhub.launch_pick(entry["id"], protocols)
|
|
1401
1473
|
if pick:
|
|
1402
1474
|
prov = pick["provider"]
|
|
@@ -1466,7 +1538,19 @@ def _sync_launch_model(entry, binding):
|
|
|
1466
1538
|
# (draft-c12 等),CLI 身份在 kind 里;目录型智能体的 id 就是条目 id。
|
|
1467
1539
|
_KIND_ENTRY = {"codex": "codex-cli", "claude": "claude-code",
|
|
1468
1540
|
"qwen": "qwencode", "opencode": "opencode", "aider": "aider"}
|
|
1469
|
-
_RUNTIME_SYNC = {"fps": {}, "lock": threading.Lock()}
|
|
1541
|
+
_RUNTIME_SYNC = {"fps": {}, "files": {}, "lock": threading.Lock()}
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _runtime_cfg_hash(entry):
|
|
1545
|
+
"""CLI 自家配置文件的内容指纹;文件不存在返回 None。"""
|
|
1546
|
+
try:
|
|
1547
|
+
p = _safe_config_path((entry.get("config") or {}).get("path"))
|
|
1548
|
+
if p and os.path.isfile(p):
|
|
1549
|
+
with open(p, "rb") as fh:
|
|
1550
|
+
return hashlib.sha256(fh.read()).hexdigest()
|
|
1551
|
+
except Exception:
|
|
1552
|
+
pass
|
|
1553
|
+
return None
|
|
1470
1554
|
|
|
1471
1555
|
|
|
1472
1556
|
def sync_runtime_config(agent):
|
|
@@ -1509,10 +1593,16 @@ def sync_runtime_config(agent):
|
|
|
1509
1593
|
fp = (model, repr(sorted(env.items())),
|
|
1510
1594
|
repr(binding.get("codex_provider") or "")[:300])
|
|
1511
1595
|
with _RUNTIME_SYNC["lock"]:
|
|
1512
|
-
if _RUNTIME_SYNC["fps"].get(entry["id"]) == fp
|
|
1596
|
+
if (_RUNTIME_SYNC["fps"].get(entry["id"]) == fp
|
|
1597
|
+
and _RUNTIME_SYNC["files"].get(entry["id"]) == _runtime_cfg_hash(entry)):
|
|
1513
1598
|
return
|
|
1599
|
+
# 绑定没变≠文件没变:指纹命中但文件被外部改写(测试毒写/手工编辑
|
|
1600
|
+
# 托管段)时必须按绑定重写——2026-09-22 kimi 实案:测试夹具毒入
|
|
1601
|
+
# ~/.kimi-code/config.toml(x.example 不可解析),换将步骤因指纹
|
|
1602
|
+
# 命中跳过重写,kimi 带毒配置静默退出码 1
|
|
1514
1603
|
_sync_launch_model(entry, binding)
|
|
1515
1604
|
_RUNTIME_SYNC["fps"][entry["id"]] = fp
|
|
1605
|
+
_RUNTIME_SYNC["files"][entry["id"]] = _runtime_cfg_hash(entry)
|
|
1516
1606
|
except Exception:
|
|
1517
1607
|
pass
|
|
1518
1608
|
|
package/app/core/market.py
CHANGED
|
@@ -290,8 +290,34 @@ def install_files(pack_id, name, files, extra=None):
|
|
|
290
290
|
record.update(extra)
|
|
291
291
|
installed[pack_id] = record
|
|
292
292
|
_save_registry(reg)
|
|
293
|
+
# 装后冒烟验证(SkillForge 证据驱动借鉴):装完重新走 skills 的解析链,
|
|
294
|
+
# 证明该包能被加载、名字对得上、正文非空——写盘成功 ≠ 技能可用,
|
|
295
|
+
# frontmatter 缺失/正文为空都能在这里当场暴露,而不是下次任务注入时静默丢失。
|
|
296
|
+
smoke = ""
|
|
297
|
+
try:
|
|
298
|
+
with skills._LOCK:
|
|
299
|
+
skills._user_pack_cache.clear()
|
|
300
|
+
skills._user_dir_mtime["ts"] = 0.0
|
|
301
|
+
skills._user_dir_mtime["ids"] = None
|
|
302
|
+
up = next((p for p in skills.user_packs()
|
|
303
|
+
if str(p.get("file") or "").replace("\\", "/").endswith("/" + target)), None)
|
|
304
|
+
if up is None:
|
|
305
|
+
smoke = "装后冒烟未找到已装包(解析失败?)"
|
|
306
|
+
elif (up.get("name") or "").strip() != name.strip():
|
|
307
|
+
smoke = "装后冒烟名字不符:%s ≠ %s" % (up.get("name"), name)
|
|
308
|
+
elif not str(up.get("body") or "").strip():
|
|
309
|
+
smoke = "装后冒烟正文为空"
|
|
310
|
+
else:
|
|
311
|
+
smoke = "ok:%d字" % len(str(up.get("body") or ""))
|
|
312
|
+
except Exception as e:
|
|
313
|
+
smoke = "装后冒烟异常: %s" % str(e)[:120]
|
|
314
|
+
with _LOCK:
|
|
315
|
+
reg = _load_registry()
|
|
316
|
+
if pack_id in (reg.get("installed") or {}):
|
|
317
|
+
reg["installed"][pack_id]["smoke"] = smoke
|
|
318
|
+
_save_registry(reg)
|
|
293
319
|
return {"ok": True, "id": pack_id, "name": name, "file": target,
|
|
294
|
-
"already": already, "scan": scan_note}, None
|
|
320
|
+
"already": already, "scan": scan_note, "smoke": smoke}, None
|
|
295
321
|
|
|
296
322
|
|
|
297
323
|
def remove(pack_id):
|
package/app/core/modelhub.py
CHANGED
|
@@ -448,6 +448,30 @@ def _drop_model_refs(data, prov, name):
|
|
|
448
448
|
_sync_chain_refs(b)
|
|
449
449
|
|
|
450
450
|
|
|
451
|
+
def _prune_binding_chains(data, prov_ids=(), pairs=()):
|
|
452
|
+
"""停用即出调度:把指向已停用供应商/模型的条目从所有绑定链里剔除。
|
|
453
|
+
|
|
454
|
+
与删除的清引用(_drop_model_refs / providers_op delete)不同,停用可逆,
|
|
455
|
+
只摘链上死条目,不动主供应商 provider_id 与厂商默认模型——重新启用后
|
|
456
|
+
原样恢复。链上剔除不回填:调度页所见即所绑,不靠运行时降级默默跳过
|
|
457
|
+
占位死条目(2026-09-22 用户拍板:禁用就直接去掉,自动就行)。
|
|
458
|
+
"""
|
|
459
|
+
ids = {i for i in (prov_ids or ()) if i}
|
|
460
|
+
prs = {(p, m) for p, m in (pairs or ()) if p and m}
|
|
461
|
+
if not ids and not prs:
|
|
462
|
+
return
|
|
463
|
+
for b in (data.get("bindings") or {}).values():
|
|
464
|
+
chain = b.get("chain")
|
|
465
|
+
if not chain:
|
|
466
|
+
continue
|
|
467
|
+
kept = [c for c in chain
|
|
468
|
+
if (c.get("provider_id") or "") not in ids
|
|
469
|
+
and (c.get("provider_id") or "", c.get("model") or "") not in prs]
|
|
470
|
+
if len(kept) != len(chain):
|
|
471
|
+
b["chain"] = kept
|
|
472
|
+
_sync_chain_refs(b)
|
|
473
|
+
|
|
474
|
+
|
|
451
475
|
_MODEL_OPS = ("enable", "disable", "delete", "restore")
|
|
452
476
|
|
|
453
477
|
|
|
@@ -475,6 +499,7 @@ def _apply_model_op(data, prov, models, name, op):
|
|
|
475
499
|
if not target.get("enabled", True):
|
|
476
500
|
return False, ""
|
|
477
501
|
target["enabled"] = False
|
|
502
|
+
_prune_binding_chains(data, pairs=[(prov.get("id"), name)])
|
|
478
503
|
elif op == "delete":
|
|
479
504
|
if target.get("hidden"):
|
|
480
505
|
return False, ""
|
|
@@ -494,6 +519,8 @@ def model_ops(provider_id, names, op):
|
|
|
494
519
|
|
|
495
520
|
先整体校验再落盘:任一名不存在、或 enable/disable 选中了已删除的模型,
|
|
496
521
|
整个请求都不生效,避免只改一半。全部改动合并为一次写盘。
|
|
522
|
+
disable 即出调度:命中的 (供应商, 模型) 条目自动从所有绑定链剔除,
|
|
523
|
+
不回填;delete 走 _drop_model_refs 连默认模型引用一起清。
|
|
497
524
|
restore 与可见模型混选时,可见项直接跳过(不算失败),便于「恢复所选」。
|
|
498
525
|
"""
|
|
499
526
|
names = list(dict.fromkeys(n for n in (names or []) if n))
|
|
@@ -656,9 +683,13 @@ def provider_view():
|
|
|
656
683
|
_KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
|
|
657
684
|
MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
|
|
658
685
|
MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
|
|
659
|
-
#
|
|
686
|
+
# 欠费/配额/限流类失败:换 KEY 有意义(同厂商另一账号=另一份额度与并发桶),
|
|
687
|
+
# 与瞬态网络错误分开记。429 限流文案一并纳入——只记错不冷却会让每个新步骤都
|
|
688
|
+
# 从超限的首选 KEY 重新烧起(2026-09-22 首选超限不切备用实案)。
|
|
660
689
|
_QUOTA_HINTS = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
|
|
661
|
-
"payment required", "402", "欠费", "余额", "额度", "exceeded"
|
|
690
|
+
"payment required", "402", "欠费", "余额", "额度", "exceeded",
|
|
691
|
+
"too many requests", "rate limit", "rate_limit", "http 429",
|
|
692
|
+
"并发", "超过限")
|
|
662
693
|
|
|
663
694
|
|
|
664
695
|
def _quota_error(err):
|
|
@@ -938,8 +969,9 @@ def upsert_provider(entry):
|
|
|
938
969
|
def providers_op(ids, op):
|
|
939
970
|
"""批量供应商操作(enable | disable | delete | duplicate)。返回 (改动数, 错误)。
|
|
940
971
|
|
|
941
|
-
|
|
942
|
-
|
|
972
|
+
停用即出调度:绑定链里它的条目自动剔除(_prune_binding_chains),配置与
|
|
973
|
+
模型列表保留,随时可再启用;已剔除的链条目不回填。运行时解析本来就会
|
|
974
|
+
跳过停用供应商,这里把落盘链一并清掉,调度页所见即所绑。
|
|
943
975
|
duplicate 复制一条(同地址同密钥的第二个账号/新网关),副本不带绑定引用。
|
|
944
976
|
"""
|
|
945
977
|
ids = list(dict.fromkeys(i for i in (ids or []) if i))
|
|
@@ -998,6 +1030,8 @@ def providers_op(ids, op):
|
|
|
998
1030
|
p["enabled"] = want
|
|
999
1031
|
changed += 1
|
|
1000
1032
|
if changed:
|
|
1033
|
+
if not want:
|
|
1034
|
+
_prune_binding_chains(data, prov_ids=ids)
|
|
1001
1035
|
# 启用置顶:本次选中的启用者排最前,其余启用跟随,停用的殿后;
|
|
1002
1036
|
# 排序稳定,各块内部保持原有顺序。落盘重排,列表页看到的就是它。
|
|
1003
1037
|
sel = set(ids)
|
|
@@ -2298,11 +2332,13 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
|
|
|
2298
2332
|
entries.append(_chain_entry_env(prov, model, target=agent_kind_or_id,
|
|
2299
2333
|
endpoint=ep, key=kk["key"],
|
|
2300
2334
|
key_id=kk.get("id") or "", provider_id=pid))
|
|
2335
|
+
mk = (chain_keys or [{}])[0] # 回退模型沿用首选可用 KEY:失败也要能记账冷却
|
|
2301
2336
|
for n in fallbacks:
|
|
2302
2337
|
if len(entries) >= MAX_CHAIN_ATTEMPTS:
|
|
2303
2338
|
break
|
|
2304
2339
|
entries.append(_chain_entry_env(prov, n, target=agent_kind_or_id,
|
|
2305
|
-
endpoint=ep,
|
|
2340
|
+
endpoint=ep, key=mk.get("key") or "",
|
|
2341
|
+
key_id=mk.get("id") or "", provider_id=pid))
|
|
2306
2342
|
head = entries[0]
|
|
2307
2343
|
out = {"model": model, "env": head["env"], "provider": prov,
|
|
2308
2344
|
"model_fallbacks": fallbacks,
|
|
@@ -2406,6 +2442,18 @@ def launch_pick(agent_id, protocols):
|
|
|
2406
2442
|
protocols = tuple(p for p in (protocols or ()) if p)
|
|
2407
2443
|
b = bindings().get(agent_id) or {}
|
|
2408
2444
|
chain = _binding_chain(b)
|
|
2445
|
+
if not chain and str(b.get("provider_id") or "").strip():
|
|
2446
|
+
# 只锁定了供应商、没排链也没选模型的绑定:与 resolve_binding 的无链分支
|
|
2447
|
+
# 同语义补一条,打开前才有凭据可注入。否则这类绑定永远「未指定供应商」,
|
|
2448
|
+
# CLI 自家配置里的残留(a.test 占位案)等不到自愈覆盖——2026-09-22
|
|
2449
|
+
# claude ENOTFOUND 四连败实测,settings.json 毒端点靠它长期存活。
|
|
2450
|
+
_pid = str(b.get("provider_id")).strip()
|
|
2451
|
+
_prov = {p.get("id"): p for p in providers()}.get(_pid)
|
|
2452
|
+
_model = str(b.get("model") or "").strip()
|
|
2453
|
+
if _prov and not _model:
|
|
2454
|
+
_names = [m["name"] for m in _enabled_models(_prov)]
|
|
2455
|
+
_model = _prov.get("model") or (_names[0] if _names else "")
|
|
2456
|
+
chain = [{"provider_id": _pid, "model": _model}]
|
|
2409
2457
|
provs = {p.get("id"): p for p in providers()}
|
|
2410
2458
|
disabled, mismatch = [], False
|
|
2411
2459
|
for item in chain:
|