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 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.54)
24
+ ### 最新版更新内容(v0.1.56)
25
25
 
26
- - 新增任务驾驶舱大屏(/board.html):模型/厂商分布、实时思考流、排队滚动
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 _post_interruptible(url, headers, body, allow_private, timeout, cancel_event):
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 cancel_event is None:
463
- th.join(timeout + 10)
464
- else:
465
- while th.is_alive() and not cancel_event.wait(0.5):
466
- pass
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 cancel_event is None:
721
- th.join(timeout + 10)
722
- else:
723
- while th.is_alive() and not cancel_event.wait(0.5):
724
- pass
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"] = "响应超时", 0
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 = _post_sse_stream(
1015
- url, headers, sbody, allow_private, timeout, proto,
1016
- on_reason=_take_reason, on_text=on_stream,
1017
- cancel_event=cancel_event)
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 = _post_interruptible(url, headers, body,
1036
- allow_private, timeout,
1037
- cancel_event)
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
- return {"target": _target, "alive": _alive,
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():
@@ -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
@@ -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 级失败回写:欠费类进冷却(后续解析自动跳过 → 切备用 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
- return proto, prov.get("base_url"), prov.get("wire_api", "responses")
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
- return [(proto, (prov or {}).get("base_url") or "")]
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]