codebee 0.1.54 → 0.1.56
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 +13 -0
- package/README.md +3 -4
- package/app/core/builtin_agent.py +67 -26
- package/app/core/jobs.py +23 -4
- package/app/core/knowledge.py +26 -4
- package/app/core/modelhub.py +50 -5
- package/app/core/pipeline.py +156 -66
- package/app/core/planner.py +27 -13
- package/app/core/runner.py +398 -121
- package/app/core/store.py +64 -27
- package/app/core/zentao.py +39 -6
- package/app/main.py +43 -3
- package/app/ui/app.js +289 -56
- package/app/ui/board.css +616 -258
- package/app/ui/board.html +119 -41
- package/app/ui/board.js +60 -14
- package/app/ui/i18n.js +2699 -2659
- package/app/ui/index.html +2 -0
- package/app/ui/style.css +8 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,19 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
23
23
|
|
|
24
24
|
- **首选 KEY 超限自动切备用了**:同厂商多密钥此前只认欠费类文案——首选 KEY 撞上 429 限流(智谱「并发数超过限制」「每分钟Token数已超过上限」、claude 的 rate_limit_error、codex 的 "429 Too Many Requests" 等)只记一笔错误、不进冷却,每个新步骤仍从超限的首选 KEY 重新烧起。现在限流文案与欠费同口径:进冷却 30 分钟、密钥镜像自动切下一把、链展开跳过冷却中的 KEY(到期或「恢复」按钮一键回首选)。顺带补一处漏账:无显式链时「换模型回退」的链条目不带 KEY 标记,这类重试失败此前完全不记 KEY 账。
|
|
25
25
|
|
|
26
|
+
## v0.1.56(2026-09-23)
|
|
27
|
+
|
|
28
|
+
- 鉴权错误不再触发换将:密钥失效类错误换哪个 CLI 都一样,直接报错省时间
|
|
29
|
+
- 超时任务提供「恢复」入口;排队任务状态文案更明确
|
|
30
|
+
- 驾驶舱轮询载荷瘦身(大屏常开不再吃带宽);移动端任务详情操作按钮对齐
|
|
31
|
+
|
|
32
|
+
## v0.1.55(2026-09-23)
|
|
33
|
+
|
|
34
|
+
- 知识库可信度分级:引用核验借鉴——知识条目带 confidence 分级,low 直接丢弃,中高按置信度加权注入
|
|
35
|
+
- 编排执行稳定化 + 模型回退修复:调用链失败时的回退路径更稳
|
|
36
|
+
- 定时任务终态徽章补全:done/failed/cancelled 三分支全可见(失败必须一眼看到)
|
|
37
|
+
- 任务驾驶舱视觉改版;禅道 Bug 扫描过滤 + 去重防重录
|
|
38
|
+
|
|
26
39
|
## v0.1.54(2026-09-23)
|
|
27
40
|
|
|
28
41
|
- 任务驾驶舱大屏:/board.html 独立页 + /api/board 聚合端点——模型/厂商分布、实时思考流、排队滚动,中英双语
|
package/README.md
CHANGED
|
@@ -21,11 +21,10 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
24
|
+
### 最新版更新内容(v0.1.56)
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- run_end 钩子支持「AI 复查后自动提交推送」;蜂巢卡显示当前模型与思考流
|
|
26
|
+
- 鉴权类错误不再无谓换将;超时任务新增「恢复」入口
|
|
27
|
+
- 驾驶舱轮询载荷瘦身;移动端详情操作对齐;排队状态文案更明确
|
|
29
28
|
<!-- relnotes:end -->
|
|
30
29
|
|
|
31
30
|
---
|
|
@@ -347,7 +347,7 @@ def _tool_write_file(workdir, args):
|
|
|
347
347
|
return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
|
|
348
348
|
|
|
349
349
|
|
|
350
|
-
def _tool_run_command(workdir, args, cancel_event=None):
|
|
350
|
+
def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
|
|
351
351
|
"""本机执行一条 shell 命令,返回 (退出码, 合并输出)。
|
|
352
352
|
|
|
353
353
|
薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
|
|
@@ -364,7 +364,7 @@ def _tool_run_command(workdir, args, cancel_event=None):
|
|
|
364
364
|
timeout = CMD_DEFAULT_TIMEOUT
|
|
365
365
|
r = runner.run_process(shell_cmd=cmdline, cwd=os.path.abspath(workdir or "."),
|
|
366
366
|
timeout=max(5, min(timeout, CMD_MAX_TIMEOUT)),
|
|
367
|
-
cancel_event=cancel_event)
|
|
367
|
+
deadline=deadline, cancel_event=cancel_event)
|
|
368
368
|
out = runner.clean_cli_text(
|
|
369
369
|
(r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
|
|
370
370
|
limit = CMD_OUT_HEAD + CMD_OUT_TAIL
|
|
@@ -405,13 +405,13 @@ _TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
|
|
|
405
405
|
"write_file": _tool_write_file, "run_command": _tool_run_command}
|
|
406
406
|
|
|
407
407
|
|
|
408
|
-
def _exec_tool(workdir, name, args, cancel_event=None):
|
|
408
|
+
def _exec_tool(workdir, name, args, cancel_event=None, deadline=None):
|
|
409
409
|
fn = _TOOL_IMPL.get(name or "")
|
|
410
410
|
if fn is None:
|
|
411
411
|
return "(未知工具: %s)" % name
|
|
412
412
|
try:
|
|
413
413
|
if name == "run_command":
|
|
414
|
-
return fn(workdir, args or {}, cancel_event=cancel_event)
|
|
414
|
+
return fn(workdir, args or {}, cancel_event=cancel_event, deadline=deadline)
|
|
415
415
|
return fn(workdir, args or {})
|
|
416
416
|
except Exception as e:
|
|
417
417
|
return "工具执行失败: %s" % (e)
|
|
@@ -443,7 +443,20 @@ def _post_json(url, headers, body, allow_private, timeout):
|
|
|
443
443
|
return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
|
|
444
444
|
|
|
445
445
|
|
|
446
|
-
def
|
|
446
|
+
def _call_with_optional_deadline(fn, args, kwargs, deadline):
|
|
447
|
+
"""Keep older test/integration transport shims source-compatible."""
|
|
448
|
+
if deadline is None:
|
|
449
|
+
return fn(*args, **kwargs)
|
|
450
|
+
try:
|
|
451
|
+
return fn(*args, **kwargs, deadline=deadline)
|
|
452
|
+
except TypeError as exc:
|
|
453
|
+
if "unexpected keyword argument 'deadline'" not in str(exc):
|
|
454
|
+
raise
|
|
455
|
+
return fn(*args, **kwargs)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event,
|
|
459
|
+
deadline=None):
|
|
447
460
|
"""可打断的模型调用:cancel_event 置位即刻放弃等待返回。
|
|
448
461
|
|
|
449
462
|
单次生成最长可跑满 timeout,取消不能陪跑到自然结束——HTTP 交给守护
|
|
@@ -459,11 +472,13 @@ def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event
|
|
|
459
472
|
|
|
460
473
|
th = threading.Thread(target=_go, daemon=True)
|
|
461
474
|
th.start()
|
|
462
|
-
if
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
475
|
+
wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
|
|
476
|
+
while th.is_alive():
|
|
477
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
478
|
+
break
|
|
479
|
+
if time.monotonic() >= wait_until:
|
|
480
|
+
return 0, None, ("任务总时限已到" if deadline is not None else "响应超时")
|
|
481
|
+
th.join(0.5)
|
|
467
482
|
if cancel_event is not None and cancel_event.is_set():
|
|
468
483
|
return 0, None, "已取消"
|
|
469
484
|
return box.get("r") or (0, None, "无响应")
|
|
@@ -641,7 +656,8 @@ def _calls_from_acc(acc):
|
|
|
641
656
|
|
|
642
657
|
|
|
643
658
|
def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
644
|
-
on_reason=None, on_text=None, on_tick=None, cancel_event=None
|
|
659
|
+
on_reason=None, on_text=None, on_tick=None, cancel_event=None,
|
|
660
|
+
deadline=None):
|
|
645
661
|
"""流式(SSE)模型调用 → dict(status/text/reasoning/calls/usage/error/events)。
|
|
646
662
|
|
|
647
663
|
与 _post_interruptible 同款的可打断语义:读取放守护线程,cancel_event 置位
|
|
@@ -717,11 +733,13 @@ def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
|
717
733
|
|
|
718
734
|
th = threading.Thread(target=_go, daemon=True)
|
|
719
735
|
th.start()
|
|
720
|
-
if
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
736
|
+
wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
|
|
737
|
+
while th.is_alive():
|
|
738
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
739
|
+
break
|
|
740
|
+
if time.monotonic() >= wait_until:
|
|
741
|
+
break
|
|
742
|
+
th.join(0.5)
|
|
725
743
|
with lock:
|
|
726
744
|
out = {"status": box["status"], "text": acc["text"],
|
|
727
745
|
"reasoning": acc["reasoning"], "usage": dict(acc["usage"]),
|
|
@@ -731,7 +749,9 @@ def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
|
731
749
|
out["error"], out["status"] = "已取消", 0
|
|
732
750
|
return out
|
|
733
751
|
if th.is_alive():
|
|
734
|
-
out["error"], out["status"] = "
|
|
752
|
+
out["error"], out["status"] = ("任务总时限已到" if deadline is not None
|
|
753
|
+
else "响应超时"), 0
|
|
754
|
+
out["deadline_exceeded"] = deadline is not None
|
|
735
755
|
return out
|
|
736
756
|
|
|
737
757
|
|
|
@@ -903,7 +923,7 @@ def _norm_usage(usage):
|
|
|
903
923
|
|
|
904
924
|
|
|
905
925
|
def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
|
|
906
|
-
on_reason=None, on_stream=None, on_activity=None, stream=True):
|
|
926
|
+
on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None):
|
|
907
927
|
"""跑一次内置智能体(内部自带工具循环直到给出最终回答)。
|
|
908
928
|
|
|
909
929
|
bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
|
|
@@ -943,6 +963,14 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
943
963
|
escalated = False # 本轮 run 是否已提额(只提一次,防无限翻倍)
|
|
944
964
|
empty_streak = 0 # 连续零正文轮数(拿到正文/工具即清零)
|
|
945
965
|
|
|
966
|
+
def _deadline_fail():
|
|
967
|
+
return {"ok": False, "text": "", "usage": dict(total_usage),
|
|
968
|
+
"error": "任务总时限已到", "model": model,
|
|
969
|
+
"provider_name": bi["provider_name"],
|
|
970
|
+
"provider_id": bi["provider_id"], "iterations": iters,
|
|
971
|
+
"cost_usd": 0.0, "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS],
|
|
972
|
+
"raw": {"timed_out": True, "deadline_exceeded": True}}
|
|
973
|
+
|
|
946
974
|
def _fire(cb, *cb_args):
|
|
947
975
|
if cb is None:
|
|
948
976
|
return
|
|
@@ -968,6 +996,8 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
968
996
|
"reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
|
|
969
997
|
|
|
970
998
|
for it in range(1, MAX_TOOL_ITERS + 1):
|
|
999
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1000
|
+
return _deadline_fail()
|
|
971
1001
|
if cancel_event is not None and cancel_event.is_set():
|
|
972
1002
|
return _fail("已取消")
|
|
973
1003
|
reasons.append("") # 每轮迭代各占一段思考(跨轮拼接时分段,join 时滤空段)
|
|
@@ -981,6 +1011,10 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
981
1011
|
got = False # 本 KEY 拿到了响应(区别于 HTTP 失败换下家)
|
|
982
1012
|
stripped = False # thinking 对象被网关 400 拒后已摘除重发
|
|
983
1013
|
while True:
|
|
1014
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1015
|
+
return _deadline_fail()
|
|
1016
|
+
req_timeout = min(float(timeout), max(0.1, deadline - time.monotonic())) \
|
|
1017
|
+
if deadline is not None else timeout
|
|
984
1018
|
url, headers, body = _build_request(
|
|
985
1019
|
proto, pbase, kk["key"], model, system, msgs,
|
|
986
1020
|
tools_ok, max_tokens=max_tokens)
|
|
@@ -1011,10 +1045,13 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1011
1045
|
text, calls, usage = "", [], {}
|
|
1012
1046
|
streamed = False
|
|
1013
1047
|
if sbody is not None:
|
|
1014
|
-
part =
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1048
|
+
part = _call_with_optional_deadline(
|
|
1049
|
+
_post_sse_stream,
|
|
1050
|
+
(url, headers, sbody, allow_private, req_timeout, proto),
|
|
1051
|
+
{"on_reason": _take_reason, "on_text": on_stream,
|
|
1052
|
+
"cancel_event": cancel_event}, deadline)
|
|
1053
|
+
if part.get("deadline_exceeded"):
|
|
1054
|
+
return _deadline_fail()
|
|
1018
1055
|
if part["error"] == "已取消":
|
|
1019
1056
|
return _fail("已取消")
|
|
1020
1057
|
if not part["error"] and part["events"]:
|
|
@@ -1032,9 +1069,12 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1032
1069
|
# 供应商稳妥;非流式也失败才走下面的记账/换链。
|
|
1033
1070
|
last_err = part["error"]
|
|
1034
1071
|
if not streamed:
|
|
1035
|
-
status, data, err =
|
|
1036
|
-
|
|
1037
|
-
|
|
1072
|
+
status, data, err = _call_with_optional_deadline(
|
|
1073
|
+
_post_interruptible,
|
|
1074
|
+
(url, headers, body, allow_private, req_timeout, cancel_event),
|
|
1075
|
+
{}, deadline)
|
|
1076
|
+
if err == "任务总时限已到":
|
|
1077
|
+
return _deadline_fail()
|
|
1038
1078
|
if cancel_event is not None and cancel_event.is_set():
|
|
1039
1079
|
# 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
|
|
1040
1080
|
return _fail("已取消")
|
|
@@ -1080,7 +1120,8 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1080
1120
|
_fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
|
|
1081
1121
|
results = []
|
|
1082
1122
|
for c in calls:
|
|
1083
|
-
out = _exec_tool(workdir, c["name"], c["args"], cancel_event
|
|
1123
|
+
out = _exec_tool(workdir, c["name"], c["args"], cancel_event,
|
|
1124
|
+
deadline=deadline)
|
|
1084
1125
|
if log:
|
|
1085
1126
|
brief = out if len(out) <= 120 else out[:120] + "…"
|
|
1086
1127
|
log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
|
package/app/core/jobs.py
CHANGED
|
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
import queue
|
|
20
20
|
import sys
|
|
21
21
|
import threading
|
|
22
|
+
import time
|
|
22
23
|
import traceback
|
|
23
24
|
|
|
24
25
|
# 仅保留给旧测试/诊断代码观察;生产 enqueue 永远不向这里写入。
|
|
@@ -323,6 +324,16 @@ def _requeue_await_slot(job, waits_left=None):
|
|
|
323
324
|
# 带着全新重试额度重新入队,封顶永远打不中)。仍满载则续下一拍。
|
|
324
325
|
with _timer_lock:
|
|
325
326
|
_deferred_timers.pop(run_id, None)
|
|
327
|
+
try:
|
|
328
|
+
from . import store
|
|
329
|
+
run = store.get_run(run_id) or {}
|
|
330
|
+
deadline_at = run.get("deadline_at")
|
|
331
|
+
if deadline_at is not None and float(deadline_at) <= time.time():
|
|
332
|
+
store.update_run(run_id, expected_status="queued", status="timeout",
|
|
333
|
+
ended_at=_now(), error="任务总时限已到(排队等待期间)")
|
|
334
|
+
return
|
|
335
|
+
except (TypeError, ValueError, AttributeError):
|
|
336
|
+
pass
|
|
326
337
|
try:
|
|
327
338
|
started = _try_start_once(job)
|
|
328
339
|
except DuplicateJobError:
|
|
@@ -333,12 +344,12 @@ def _requeue_await_slot(job, waits_left=None):
|
|
|
333
344
|
return
|
|
334
345
|
if _restart_drain:
|
|
335
346
|
_close_unstarted(job, "服务正在完成升级重启;本次未排队,请稍后重试",
|
|
336
|
-
statuses=("running"
|
|
347
|
+
statuses=("queued", "running"))
|
|
337
348
|
elif waits_left > 0:
|
|
338
349
|
_requeue_await_slot(job, waits_left - 1)
|
|
339
350
|
else:
|
|
340
351
|
_close_unstarted(job, "并发位已满,排队等待超时仍未起跑,请稍后重试",
|
|
341
|
-
statuses=("running"
|
|
352
|
+
statuses=("queued", "running"))
|
|
342
353
|
|
|
343
354
|
with _timer_lock:
|
|
344
355
|
if run_id in _deferred_timers:
|
|
@@ -774,11 +785,19 @@ def requeue_pending(limit=10, max_age_s=None):
|
|
|
774
785
|
|
|
775
786
|
def workers_info():
|
|
776
787
|
with _pool_lock:
|
|
777
|
-
|
|
788
|
+
info = {"target": _target, "alive": _alive,
|
|
778
789
|
"chat_alive": _chat_alive, "chat_pool": CHAT_POOL,
|
|
779
|
-
"queued": 0,
|
|
780
790
|
"available": 0 if _restart_drain else max(0, _target - _alive),
|
|
781
791
|
"mode": "restart-drain" if _restart_drain else "direct"}
|
|
792
|
+
# queued runs are deliberately persisted rather than held in _QUEUE. Report
|
|
793
|
+
# the same source the board renders so capacity waits are observable.
|
|
794
|
+
try:
|
|
795
|
+
from . import store
|
|
796
|
+
info["queued"] = sum(1 for run in store.list_runs(None)
|
|
797
|
+
if run.get("status") == "queued")
|
|
798
|
+
except Exception:
|
|
799
|
+
info["queued"] = 0
|
|
800
|
+
return info
|
|
782
801
|
|
|
783
802
|
|
|
784
803
|
def _drain_test_queue():
|
package/app/core/knowledge.py
CHANGED
|
@@ -97,7 +97,7 @@ _NEAR_DUP_SIM = 0.8 # 同 scope 标题包含度 ≥0.8 视为同一条(近
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
100
|
-
as_of=None, status="draft"):
|
|
100
|
+
as_of=None, status="draft", confidence=None):
|
|
101
101
|
"""写入/合并一条知识。同 scope 同标题视为同一条(指纹去重):
|
|
102
102
|
- 已有条目是 approved 且新条是 draft:不覆盖正文(人工确认过的内容优先),
|
|
103
103
|
新内容记入 revisions 作为修订候选,seen+1;
|
|
@@ -145,6 +145,8 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
|
145
145
|
it["tags"] = (it.get("tags") or [])[:8]
|
|
146
146
|
if as_of:
|
|
147
147
|
it["as_of"] = str(as_of)[:10]
|
|
148
|
+
if confidence: # 合并时也可提升/回落置信度(high 覆盖 medium 合理)
|
|
149
|
+
it["confidence"] = confidence
|
|
148
150
|
if source:
|
|
149
151
|
it["source"] = source
|
|
150
152
|
if source_file:
|
|
@@ -156,6 +158,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
|
156
158
|
"tags": tags, "status": status, "enabled": True,
|
|
157
159
|
"source": source, "source_file": source_file,
|
|
158
160
|
"as_of": str(as_of or _now()[:10])[:10],
|
|
161
|
+
"confidence": confidence or "medium",
|
|
159
162
|
"revisions": [], "hits": 0, "seen": 1,
|
|
160
163
|
"created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
|
|
161
164
|
items.append(it)
|
|
@@ -248,6 +251,11 @@ def block_for(task):
|
|
|
248
251
|
stale = _is_stale(x.get("as_of"))
|
|
249
252
|
mark = ("(事实截至 %s,可能过期,请自行核实时效)" % x.get("as_of") if stale
|
|
250
253
|
else ("(事实截至 %s)" % x.get("as_of") if x.get("as_of") else ""))
|
|
254
|
+
# 置信标注(引用核验续):medium 不标(默认噪音为零),low 早已
|
|
255
|
+
# 在学习口丢弃——只有 high 显式加冕,模型据以分配采信权重
|
|
256
|
+
conf = str(x.get("confidence") or "medium").strip().lower()
|
|
257
|
+
if conf == "high":
|
|
258
|
+
mark += "[有据]"
|
|
251
259
|
lines.append("- **%s**%s:%s" % (x["title"], mark, x["body"]))
|
|
252
260
|
used.append(x["id"])
|
|
253
261
|
text = "## 知识库(已确认的领域知识,供参考)\n\n" + "\n".join(lines)
|
|
@@ -277,10 +285,17 @@ KNOWLEDGE_PROMPT = """你是编排系统的知识管理员。下面是一次任
|
|
|
277
285
|
- fact:回答「是什么」,只含领域事实、外部规则和可验证结论,进入知识库;
|
|
278
286
|
- practice:回答「怎么做」,含流程、操作方法和正向实践,进入经验库;此类必须填写 category。
|
|
279
287
|
「下次要避免什么」这类负面教训由评审复盘流程负责,不要重复提炼。
|
|
288
|
+
|
|
289
|
+
**可信度分级(引用核验借鉴)**:每条 fact 必须给出 confidence:
|
|
290
|
+
- "high":产出材料中有明确来源/数据支撑(引用了来源、给了数字口径);
|
|
291
|
+
- "medium":材料内部自洽但未标来源(模型整理所得);
|
|
292
|
+
- "low":你不确定、可能过时或与材料其他部分矛盾——这类宁可不要,直接丢弃。
|
|
293
|
+
fact 缺 confidence 视为 medium。
|
|
294
|
+
|
|
280
295
|
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
281
|
-
{"entries": [{"kind": "fact 或 practice", "title": "≤20 字的标题", "body": "具体内容(≤200 字,自含上下文,脱离本次任务也能看懂)", "category": "practice 的经验分类", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(fact 的事实采集日)"}]}
|
|
296
|
+
{"entries": [{"kind": "fact 或 practice", "title": "≤20 字的标题", "body": "具体内容(≤200 字,自含上下文,脱离本次任务也能看懂)", "category": "practice 的经验分类", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(fact 的事实采集日)", "confidence": "high/medium/low(fact 必填)"}]}
|
|
282
297
|
category 只能从以下枚举选择:__CATEGORIES__。
|
|
283
|
-
最多 3
|
|
298
|
+
最多 3 条,只保留有明确复用价值的;low 置信的直接丢弃;产出里没有值得沉淀的就返回空数组。
|
|
284
299
|
|
|
285
300
|
## 任务类型
|
|
286
301
|
__TYPE__
|
|
@@ -381,12 +396,19 @@ def learn_from_run(run_id):
|
|
|
381
396
|
dim="%s %s" % (x["title"], x["body"])):
|
|
382
397
|
n += 1
|
|
383
398
|
continue
|
|
399
|
+
# 可信度分级(引用核验借鉴):low 直接丢弃(宁缺毋滥),
|
|
400
|
+
# 其余归一 high/medium 并随条目落盘;缺失视为 medium
|
|
401
|
+
conf = str(x.get("confidence") or "medium").strip().lower()
|
|
402
|
+
if conf not in ("high", "medium"):
|
|
403
|
+
if conf == "low":
|
|
404
|
+
continue
|
|
405
|
+
conf = "medium"
|
|
384
406
|
as_of = str(x.get("as_of") or "").strip()
|
|
385
407
|
if not re.match(r"^\d{4}-\d{2}-\d{2}$", as_of):
|
|
386
408
|
as_of = _now()[:10]
|
|
387
409
|
if upsert_entry(task.get("type") or "*", x["title"], x["body"],
|
|
388
410
|
tags=x.get("tags"), source=run_id,
|
|
389
|
-
as_of=as_of, status="approved"):
|
|
411
|
+
as_of=as_of, status="approved", confidence=conf):
|
|
390
412
|
n += 1
|
|
391
413
|
except Exception:
|
|
392
414
|
return n
|
package/app/core/modelhub.py
CHANGED
|
@@ -681,6 +681,7 @@ def provider_view():
|
|
|
681
681
|
# 读取点(凭据注入/健康探测/取模型列表)无需感知多 KEY 结构。没配 keys 的老数据
|
|
682
682
|
# 由 _provider_keys 按 api_key 现场合成一条——文件一个字节都不用改。
|
|
683
683
|
_KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
|
|
684
|
+
_AUTH_COOLDOWN_S = 30 * 60 # 明确认证拒绝后的冷却时长,可在 UI 手动恢复
|
|
684
685
|
MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
|
|
685
686
|
MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
|
|
686
687
|
# 欠费/配额/限流类失败:换 KEY 有意义(同厂商另一账号=另一份额度与并发桶),
|
|
@@ -699,6 +700,19 @@ def _quota_error(err):
|
|
|
699
700
|
return any(k in e for k in _QUOTA_HINTS)
|
|
700
701
|
|
|
701
702
|
|
|
703
|
+
_AUTH_HINTS = ("invalid api key", "invalid_api_key", "api key is invalid",
|
|
704
|
+
"incorrect api key", "unauthorized", "unauthorised",
|
|
705
|
+
"authentication failed", "authentication error",
|
|
706
|
+
"authentication_error", "invalid token", "invalid_token",
|
|
707
|
+
"http 401", "status code 401")
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _auth_error(err):
|
|
711
|
+
"""明确的凭据认证拒绝;不把一般 403/模型权限错误误判成 KEY 错误。"""
|
|
712
|
+
e = (err or "").lower()
|
|
713
|
+
return any(k in e for k in _AUTH_HINTS)
|
|
714
|
+
|
|
715
|
+
|
|
702
716
|
def _provider_keys(prov, available_only=False, now=None):
|
|
703
717
|
"""供应商的 KEY 列表,顺序=调用顺序。返回 [{id,key,label,enabled,cooling,...}]。
|
|
704
718
|
|
|
@@ -826,7 +840,7 @@ def key_op(provider_id, op, key_id="", key="", label="", enabled=None, ids=None)
|
|
|
826
840
|
|
|
827
841
|
|
|
828
842
|
def note_key_error(provider_id, key_id, error=""):
|
|
829
|
-
"""一次 KEY
|
|
843
|
+
"""一次 KEY 级失败回写:欠费或明确认证拒绝进冷却,后续解析切备用 KEY。"""
|
|
830
844
|
import time as _t
|
|
831
845
|
if not provider_id or not key_id:
|
|
832
846
|
return
|
|
@@ -842,6 +856,8 @@ def note_key_error(provider_id, key_id, error=""):
|
|
|
842
856
|
target["last_fail_at"] = _t.time()
|
|
843
857
|
if _quota_error(error):
|
|
844
858
|
target["cool_until"] = _t.time() + _KEY_COOLDOWN_S
|
|
859
|
+
elif _auth_error(error):
|
|
860
|
+
target["cool_until"] = _t.time() + _AUTH_COOLDOWN_S
|
|
845
861
|
_sync_api_key(prov)
|
|
846
862
|
_save(data)
|
|
847
863
|
|
|
@@ -2156,11 +2172,14 @@ def _entry_endpoint(prov, allowed):
|
|
|
2156
2172
|
"""
|
|
2157
2173
|
proto = prov.get("protocol")
|
|
2158
2174
|
if proto in allowed:
|
|
2159
|
-
|
|
2175
|
+
base = prov.get("base_url")
|
|
2176
|
+
if not _wire_endpoint_compatible(proto, base):
|
|
2177
|
+
return None
|
|
2178
|
+
return proto, base, prov.get("wire_api", "responses")
|
|
2160
2179
|
caps = prov.get("wire_caps") or {}
|
|
2161
2180
|
for p in allowed:
|
|
2162
2181
|
cap = caps.get(p) or {}
|
|
2163
|
-
if cap.get("base"):
|
|
2182
|
+
if cap.get("base") and _wire_endpoint_compatible(p, cap["base"]):
|
|
2164
2183
|
return p, cap["base"], cap.get("wire_api") or "responses"
|
|
2165
2184
|
return None
|
|
2166
2185
|
|
|
@@ -2172,10 +2191,12 @@ def _protocol_candidates(prov):
|
|
|
2172
2191
|
调用方逐个试、全失败才报错——不猜一条去发请求。"""
|
|
2173
2192
|
proto = (prov or {}).get("protocol")
|
|
2174
2193
|
if proto in _PROTOCOLS:
|
|
2175
|
-
|
|
2194
|
+
base = (prov or {}).get("base_url") or ""
|
|
2195
|
+
return ([(proto, base)] if _wire_endpoint_compatible(proto, base) else [])
|
|
2176
2196
|
caps = (prov or {}).get("wire_caps") or {}
|
|
2177
2197
|
return [(p, caps[p]["base"]) for p in _WIRE_PREFERENCE
|
|
2178
|
-
if (caps.get(p) or {}).get("base")
|
|
2198
|
+
if (caps.get(p) or {}).get("base")
|
|
2199
|
+
and _wire_endpoint_compatible(p, (caps.get(p) or {}).get("base"))]
|
|
2179
2200
|
|
|
2180
2201
|
|
|
2181
2202
|
def _ensure_wire_candidates(provider_id, prov, model_name=""):
|
|
@@ -2195,6 +2216,26 @@ def _ensure_wire_candidates(provider_id, prov, model_name=""):
|
|
|
2195
2216
|
return (_protocol_candidates(prov2) if prov2 else []), (note or "")
|
|
2196
2217
|
|
|
2197
2218
|
|
|
2219
|
+
def _wire_endpoint_compatible(proto, base_url):
|
|
2220
|
+
"""Reject an endpoint whose URL path contradicts the selected wire.
|
|
2221
|
+
|
|
2222
|
+
Some providers expose an Anthropic API under a path containing ``anthropic``
|
|
2223
|
+
and may have stale ``wire_caps`` from an earlier probe that recorded that
|
|
2224
|
+
same URL as OpenAI/Responses. Codex then retries forever because it sends
|
|
2225
|
+
Responses events to the Anthropic route. A path marker is a conservative
|
|
2226
|
+
local guard; normal shared roots such as ``https://host/v1`` remain valid
|
|
2227
|
+
for both protocols.
|
|
2228
|
+
"""
|
|
2229
|
+
if proto != "openai" or not base_url:
|
|
2230
|
+
return True
|
|
2231
|
+
try:
|
|
2232
|
+
import urllib.parse
|
|
2233
|
+
path = (urllib.parse.urlsplit(str(base_url)).path or "").lower()
|
|
2234
|
+
except Exception:
|
|
2235
|
+
return True
|
|
2236
|
+
return re.search(r"(?:^|/)anthropic(?:/|$)", path) is None
|
|
2237
|
+
|
|
2238
|
+
|
|
2198
2239
|
def bindable_protocols(agent_kind_or_id):
|
|
2199
2240
|
"""该 CLI 可绑定的 wire 协议(与 resolve_binding 的 allowed 一致)。
|
|
2200
2241
|
供死链告警/失败文案解释「为什么绑不上」:claude 只认 anthropic,
|
|
@@ -2851,6 +2892,10 @@ def _wire_base_candidates(base_url, target_proto):
|
|
|
2851
2892
|
cands = [base if base.endswith("/v1") else base + "/v1"]
|
|
2852
2893
|
if not base.endswith("/v1"):
|
|
2853
2894
|
cands.append(base)
|
|
2895
|
+
# `/api/anthropic` 是 Anthropic 专用入口,不要把它拼成
|
|
2896
|
+
# `/api/anthropic[/v1]/responses` 再误记为 Codex 能力。
|
|
2897
|
+
if not _wire_endpoint_compatible(target_proto, base):
|
|
2898
|
+
cands = []
|
|
2854
2899
|
else:
|
|
2855
2900
|
# anthropic 习惯:CLI 在 base 后拼 /v1/messages,base 本身不含 /v1
|
|
2856
2901
|
cands = [base[:-3] if base.endswith("/v1") else base]
|