codebee 0.1.4 → 0.1.6

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.
@@ -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]
@@ -435,6 +454,7 @@ def model_ops(provider_id, names, op):
435
454
  if changed:
436
455
  _promote(models, newly_enabled) # 启用的置顶,停用的退到启用块之后
437
456
  _save(data)
457
+ sync_binding_alerts() # 模型启停/恢复 → 绑定链死活立即刷新告警
438
458
  return changed, ""
439
459
 
440
460
 
@@ -454,6 +474,7 @@ def _restore_all(provider_id):
454
474
  m["enabled"] = True
455
475
  _promote(models, names) # 恢复 = 重新启用:同样置顶
456
476
  _save(data)
477
+ sync_binding_alerts()
457
478
  return None
458
479
 
459
480
 
@@ -729,15 +750,48 @@ def note_key_ok(provider_id, key_id):
729
750
  with _LOCK:
730
751
  data = _load()
731
752
  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):
753
+ if not prov:
733
754
  return
734
- target = next((k for k in prov["keys"] if str(k.get("id")) == str(key_id)), None)
755
+ target = next((k for k in (prov.get("keys") or []) if str(k.get("id")) == str(key_id)), None)
735
756
  if target is None or not target.get("last_error"):
736
757
  return
737
758
  target.pop("last_error", None)
738
759
  _save(data)
739
760
 
740
761
 
762
+ def _is_codex_target(target):
763
+ return (target or "").strip().lower() in ("codex-cli", "codex", "codex-code")
764
+
765
+
766
+ def note_codex_wire_dead(provider_id, minutes=30):
767
+ """codex 撞上 wire 不兼容的供应商 → 供应商级冷却(自动绕开的记账位)。
768
+
769
+ codex 0.154 起只支持 responses wire(chat 被官方移除),讯飞等只有
770
+ chat completions 的 MaaS 每次 404/启动即拒。runner 按 404+no Route
771
+ matched / wire_api no longer supported 特征自动调用本函数;链展开与
772
+ 路由绑定加分据此自动绕开,无需人工改绑定(2026-09-17 mo-so 实测)。
773
+ """
774
+ import time as _t
775
+ if not provider_id:
776
+ return
777
+ with _LOCK:
778
+ data = _load()
779
+ prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
780
+ if not prov:
781
+ return
782
+ prov["codex_wire_dead_until"] = _t.time() + max(1, int(minutes)) * 60
783
+ _save(data)
784
+
785
+
786
+ def codex_wire_blocked(prov):
787
+ """该供应商是否处于 codex wire 不兼容冷却期。"""
788
+ import time as _t
789
+ try:
790
+ return float((prov or {}).get("codex_wire_dead_until") or 0) > _t.time()
791
+ except Exception:
792
+ return False
793
+
794
+
741
795
  def _is_private_host(url):
742
796
  """主机是私网/环回 IP 字面量或 localhost 时返回 True(自动放行内网网关)。"""
743
797
  import urllib.parse
@@ -873,6 +927,8 @@ def providers_op(ids, op):
873
927
  not (p.get("id") in sel and p.get("enabled", True)),
874
928
  not bool(p.get("enabled", True))))
875
929
  _save(data)
930
+ if changed or op == "delete":
931
+ sync_binding_alerts() # 厂商启停/删除 → 绑定链死活立即刷新告警
876
932
  return changed, ("" if changed else "所选供应商已是目标状态")
877
933
 
878
934
 
@@ -1813,6 +1869,14 @@ def _model_bindable(prov, model):
1813
1869
  return True
1814
1870
 
1815
1871
 
1872
+ def _model_image_in(prov, model):
1873
+ """模型是否声明支持图片输入:models[] 条目 image_in;缺省/查不到=False(纯文本)。"""
1874
+ for m in ((prov or {}).get("models") or []):
1875
+ if isinstance(m, dict) and m.get("name") == model:
1876
+ return bool(m.get("image_in"))
1877
+ return False
1878
+
1879
+
1816
1880
  def bind_agent(agent, difficulty="default"):
1817
1881
  """按绑定生成应用了供应商/模型覆盖的 agent 副本;无绑定时原样返回。"""
1818
1882
  r = resolve_binding(agent.get("id"), difficulty) or resolve_binding(agent.get("kind"), difficulty)
@@ -1907,6 +1971,56 @@ def _protocol_candidates(prov):
1907
1971
  if (caps.get(p) or {}).get("base")]
1908
1972
 
1909
1973
 
1974
+ def bindable_protocols(agent_kind_or_id):
1975
+ """该 CLI 可绑定的 wire 协议(与 resolve_binding 的 allowed 一致)。
1976
+ 供死链告警/失败文案解释「为什么绑不上」:claude 只认 anthropic,
1977
+ codex/dsh 只认 openai,其余开放双协议(含 wire 适配)。"""
1978
+ if _deepseek_env_target(agent_kind_or_id) or agent_kind_or_id in ("codex-cli", "codex"):
1979
+ return ("openai",)
1980
+ if agent_kind_or_id in ("claude-code", "claude"):
1981
+ return ("anthropic",)
1982
+ return tuple(_BINDABLE_PROTOCOLS)
1983
+
1984
+
1985
+ def binding_dead_msg(cli_id):
1986
+ """死链失败/告警文案(pipeline 死链闸门与本模块 sync 共用):说明为什么
1987
+ 不回落本机默认 + 该 CLI 需要什么协议的供应商。"""
1988
+ try:
1989
+ protos = bindable_protocols(cli_id)
1990
+ except Exception:
1991
+ protos = ()
1992
+ hint = ("该 CLI 仅接受 %s 协议的已启用供应商;" % "、".join(protos)) if protos else ""
1993
+ return ("绑定链全部失效(链上供应商已停用/删除/无密钥,或模型已停用),"
1994
+ "本步判失败、不回落 CLI 本机默认——%s请在「CLI 绑定」页为该 CLI 绑定已启用的供应商" % hint)
1995
+
1996
+
1997
+ def sync_binding_alerts():
1998
+ """厂商/模型启停、删除、恢复后立即重评估各 CLI 绑定链的静态死链告警:
1999
+ 恢复的当场解除、新死的当场亮起——不必等下一次步骤执行才发现
2000
+ (2026-09-17 起与死链硬失败闸门配套)。幂等:health 侧静默/恢复语义不变。"""
2001
+ from . import health
2002
+ try:
2003
+ binds = bindings()
2004
+ except Exception:
2005
+ return
2006
+ for cli_id in sorted(binds.keys()):
2007
+ b = binds.get(cli_id) or {}
2008
+ if not _binding_chain(b):
2009
+ continue # 没配过链的 CLI 不归静态告警管(执行层闸门在跑时兜)
2010
+ try:
2011
+ r = resolve_binding(cli_id)
2012
+ ok = bool(r and r.get("call_chain"))
2013
+ except Exception:
2014
+ ok = False
2015
+ try:
2016
+ if ok:
2017
+ health.report_binding_ok(cli_id)
2018
+ else:
2019
+ health.report_binding_dead(cli_id, binding_dead_msg(cli_id))
2020
+ except Exception:
2021
+ pass # 告警是尽力而为的旁路:persist 失败(如目录不可用)不拖累操作本身
2022
+
2023
+
1910
2024
  def resolve_binding(agent_kind_or_id, difficulty="default"):
1911
2025
  """返回 {env:{}, model:..., model_fallbacks:[...], codex_provider:..., call_chain:[...]} 或 None。
1912
2026
 
@@ -1921,17 +2035,11 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
1921
2035
  provs = {p.get("id"): p for p in providers()}
1922
2036
  routing = bool(b.get("difficulty_routing"))
1923
2037
  tier = difficulty if difficulty in ("easy", "hard") else None
1924
- # dsh DEEPSEEK_* env,端点必须是 OpenAI 兼容的 /chat/completions,
1925
- # anthropic 协议的网关注进去也调不通,直接判为不可绑定。
1926
- allowed = ("openai",) if _deepseek_env_target(agent_kind_or_id) else _BINDABLE_PROTOCOLS
1927
- # 2026-09-15 连载验收实测:CLI 与供应商协议必须匹配——codex 只吃 openai wire
1928
- # (codex_provider 机制),claude 只吃 anthropic wire。混着注入会产生
1929
- # 「codex 拿到 ANTHROPIC_* env 却缺 ORCH_API_KEY」这类必然失败的组合
1930
- # (症状:Missing environment variable: ORCH_API_KEY)。
1931
- if agent_kind_or_id in ("codex-cli", "codex"):
1932
- allowed = ("openai",)
1933
- elif agent_kind_or_id in ("claude-code", "claude"):
1934
- allowed = ("anthropic",)
2038
+ # 协议必须匹配(bindable_protocols):dsh 只吃 OpenAI 兼容端点,codex 只吃
2039
+ # openai wire(codex_provider 机制),claude 只吃 anthropic wire——混着注入
2040
+ # 会产生「codex 拿到 ANTHROPIC_* env 却缺 ORCH_API_KEY」这类必然失败的组合
2041
+ # 2026-09-15 连载验收实测,症状:Missing environment variable: ORCH_API_KEY)。
2042
+ allowed = bindable_protocols(agent_kind_or_id)
1935
2043
 
1936
2044
  if chain:
1937
2045
  entries = []
@@ -1958,6 +2066,10 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
1958
2066
  continue # 原生协议与适配过的 wire 都不匹配:跳过
1959
2067
  if prov.get("name") in down_set:
1960
2068
  continue # 健康监测判定 down:跳过,省掉无效等待
2069
+ if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
2070
+ continue # codex 0.154+ 只讲 responses wire:chat-only 供应商在起跑前
2071
+ # 就剔除(此前撞了才冷却 30 分钟,每轮白烧一次注定失败的
2072
+ # 尝试——2026-09-17 续4 连载 c35 实测)
1961
2073
  if model and not _model_bindable(prov, model):
1962
2074
  continue # 模型被停用/删除:该条跳过(2026-09-15 告警弹框「禁用该模型」)
1963
2075
  # 多 KEY:同一厂商按 KEY 展开成多条,顺序即调用顺序。欠费的 KEY 被
@@ -1996,6 +2108,9 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
1996
2108
  ep = _entry_endpoint(prov, allowed)
1997
2109
  if not ep:
1998
2110
  return None # google 只登记;dsh 只接受 OpenAI 兼容端点;未适配的不硬塞
2111
+ if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
2112
+ return None # codex 0.154+ 只讲 responses:chat-only 供应商直接判不可绑
2113
+ # (解析为空 → 死链闸门/路由降权接手,不浪费 CLI 尝试)
1999
2114
  names = [m["name"] for m in _enabled_models(prov)]
2000
2115
  model = prov.get("model_" + tier) or "" if (routing and tier) else ""
2001
2116
  if not model:
@@ -2173,6 +2288,24 @@ def reorder_models(provider_id, ordered_names):
2173
2288
  return None
2174
2289
 
2175
2290
 
2291
+ def set_model_caps(provider_id, name, image_in):
2292
+ """声明单个模型的模态能力(当前仅 image_in 图片输入)。返回错误文案或 None。"""
2293
+ with _LOCK:
2294
+ data = _load()
2295
+ prov = next((p for p in data.get("providers", []) if p.get("id") == provider_id), None)
2296
+ if not prov:
2297
+ return "供应商不存在"
2298
+ target = next((m for m in (prov.get("models") or [])
2299
+ if m.get("name") == name), None)
2300
+ if target is None:
2301
+ return "模型不存在: %s" % name
2302
+ image_in = bool(image_in)
2303
+ if bool(target.get("image_in")) != image_in: # 有差异才落盘
2304
+ target["image_in"] = image_in
2305
+ _save(data)
2306
+ return None
2307
+
2308
+
2176
2309
  def _post_json_http(url, headers, body, allow_private, timeout=20):
2177
2310
  """带 SSRF 防护的 POST。返回 (status, json_obj|None, err)。"""
2178
2311
  import urllib.parse