Skip to content

feat(omni): 支持 GLM-4.6V 多模态模型(智谱 API) - #482

Open
0xhyperdan wants to merge 10 commits into
XiaoMi:mainfrom
0xhyperdan:feat/glm-adapter
Open

feat(omni): 支持 GLM-4.6V 多模态模型(智谱 API)#482
0xhyperdan wants to merge 10 commits into
XiaoMi:mainfrom
0xhyperdan:feat/glm-adapter

Conversation

@0xhyperdan

@0xhyperdan 0xhyperdan commented Jul 31, 2026

Copy link
Copy Markdown

背景

Miloco 感知引擎目前仅支持 MiMo(默认)/ Qwen / Gemini 三类 omni 模型。用户希望接入智谱 GLM 多模态模型(如 GLM-4.6V)。

改动

provider.py 新增 GlmAdapter(继承 MiMoAdapter):

  • 视频请求体与 MiMo 同构:GLM-4.6V 接受 video_url + fps + media_resolutionthinking: disabled 等字段(已实测兼容,含流式)
  • 不支持音频输入:GLM-4.6V 是纯视觉语言模型(文本/图片/视频/文件),input_audio 属于 GLM-4-Voice / GLM-Realtime 线——实测 m4a / wav 均被 400 拒
  • get_adapter() 按模型名子串匹配("glm" in model.lower())路由到 GlmAdapter,故 glm-4.6v / glm-4.6v-flash / zhipu/glm-4.6v 均命中

音频降级(audio-only 窗口)

GLM 不支持 input_audio,若夜间「有声音、画面静止」的窗口照发音频块会稳定 400,把熔断打进需人工干预的 CONFIG 态。修复:

  • OmniProviderAdapter 新增 supports_audio_input 能力位(默认 True,现有 adapter 行为不变);GlmAdapterFalse
  • 路由级降级:听不见音频的 provider 在 audio-only 窗口退回 video 路由(帧本来就在,至少还有画面证据),且 has_audio / has_speech 标注同步置 False——prompt 不再声称「本轮有音频」,既有机制会把 speeches / env_sounds 从 schema 剥掉,避免空烧钱和脑补
  • omni_client on_demand 路径按能力位不发音频块
  • build_prompt 系列 / run_omni 系列透传 adapter

GLM Coding Plan 的 X-Title 头:用户配置,默认不携带

X-Title: 4.5V MCP Local 是智谱 GLM Coding Plan 的 MCP 流量标识,Coding Plan Key 必须携带否则 429(余额不足)。这是计费口径 / 服务条款层面的选择,不作为仓库默认行为:

  • OmniModelSettings / OmniConfig 新增 extra_headers 字段(默认空)
  • 所有请求头组装点合并 config.extra_headersmodel.omni → perception.engine 下推 validator 同步透传该字段
  • 使用 Coding Plan Key 的用户自行配置:
    { "model": { "omni": { "api_key": "<Key>", "model": "glm-4.6v",
        "base_url": "https://open.bigmodel.cn/api/paas/v4",
        "extra_headers": { "X-Title": "4.5V MCP Local" } } } }
  • base_url 用 /api/paas/v4(Coding Plan 的 /api/coding/paas/v4 不支持视觉输入)
  • 非 Coding Plan 的 GLM Key 是否要求该头未验证

测试

  • 新增 TestGlmAdapter:路由(含大小写不敏感)、OpenAI 兼容族归属、supports_audio_input = False、auth_headers 纯 Bearer(不硬编码 X-Title)
  • 新增 extra_headers 生效/默认两个客户端测试
  • audio 降级用例:GLM 的 audio-only 窗口不发 input_audio
  • singleton 测试覆盖 glm-4.6v vs glm-4.6v-flash
  • omni + probe + config 相关 398 个测试全部通过

- 新增 GlmAdapter: 继承 MiMoAdapter, 视频块/请求体同构 (video_url + fps +
  media_resolution / thinking:disabled 实测兼容)
- get_adapter 按模型名子串匹配 ("glm" in model.lower()) 路由到 GlmAdapter,
  glm-4.6v / glm-4.6v-flash / zhipu/glm-4.6v 均命中
- GLM-4.6V 是纯视觉模型, 不支持 input_audio (音频降级见后续 commit);
  X-Title 由用户经 model.omni.extra_headers 配置 (见后续 commit)
@github-actions

Copy link
Copy Markdown

👋 感谢提交 PR @0xhyperdan!维护者会尽快 review。

提交前请确认:

  • CI 全绿(test / lint / build)
  • 改动聚焦单一主题,便于审阅
  • 若改动了依赖(lockfile / pyproject.toml / package.json),需维护者评论 /allow-dependencies-change <当前 head SHA> 放行(之后再 push 需重新放行)

@CLAassistant

CLAassistant commented Jul 31, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

X-Title: 4.5V MCP Local 是 Coding Plan Key 必须携带的标识,
不带会按普通资源包计费并返回 429 余额不足。
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown

[PR #482]: feat(omni): 支持 GLM-4.6V 多模态模型(智谱 API)

作者: 0xhyperdan
范围: feat/glm-adapter → main

修改方案

要解决的问题

智谱 GLM-4.6V 走的是 OpenAI 兼容协议,看起来可以直接复用现有的 MiMo 适配器,但有两处不兼容会让接入直接失败:一是 GLM-4.6V 是纯视觉语言模型,收到 input_audio 消息块会被 400 拒,而感知管线在"这一批只有声音、画面没变化"时正好会走音频路;二是部分网关(智谱 Coding Plan 签发的 Key)要求请求里带上自定义标识头才走对通道,而现有链路只发固定的三个头,用户没有任何地方能加。

整体方案

三条主线互相独立:一条改推理时的路由分叉,一条打通自定义请求头的配置通道,一条把这份配置接进 Web 管理端。

主线 1 — 给"这家模型能不能听声音"加一个能力位,纯音频窗口在听不见的模型上退回按视频处理

  • 在所有 provider 适配器的基类上加一个布尔开关,声明这家模型是否接受音频输入,默认给 True——也就是"能听",让所有已有的接入(MiMo、Qwen、Gemini)行为一字不变(OmniProviderAdapter

  • 新增智谱的适配器,直接继承 MiMo 那套(请求体、鉴权头、fps / media_resolution 参数、关思考链都实测同构),整个类体里只覆写那一个开关为"不能听"(GlmAdapter

  • 按模型名挑适配器的分发处加一条判断:名字里带 glm 的走新适配器,其余不动(get_adapter

  • 组装 payload 时先照旧算出这一批感知该走视频路还是音频路,紧接着补一步判断:如果算出来是音频路、但接的模型听不见,就把路由改成视频路——理由是帧一直都在(只是画面没变化才判成音频路),改送画面至少还有一份真证据,比降级成纯文字强(_build_payloadbuild_fused_payload

  • 翻转之后还要防止 prompt 声称"本轮有音频":原本视频路下由音频闸门和人声检测决定的两个开关,再和"模型能不能听"取一次与,听不见就一律按无音频、无人声处理,schema 里的人声段和环境音段随之被剥掉,模型不会就着画面脑补声音(_build_payload

  • 最底层拼 messages 的地方加同一道闸:只有当这家模型能听时,音频数据才被拼成 input_audio 块,否则整块不发(_build_messages

    路由判定表("这批感知"= 一个感知窗口内的全部帧与音频):

    这批感知的内容 接的模型能否听音频 最终走哪条路 音频怎么处理
    画面有变化 能(MiMo / Qwen / Gemini) 视频 过了闸门就带上
    画面有变化 不能(GLM) 视频 整段丢弃,has_audio / has_speech 强制为假
    只有声音、画面无变化 音频 编成音频块单发
    只有声音、画面无变化 不能(GLM) 视频(翻转) 整段丢弃,改送静态帧

主线 2 — 加一份用户自定义请求头,从配置文件一路透传到每一个发请求的地方

  • 在 omni 的模型配置里新增一个字符串字典字段,默认空字典,用来放任意附加请求头(OmniModelSettings.extra_headers

  • 配置加载完成后有一个把顶层模型配置下推给感知层的校验器,在它拷贝 model / base_url / api_key 的地方一并把这份头拷过去,这样感知引擎自己的配置对象也拿得到(_propagate_model_omni_to_perceptionOmniConfig.extra_headers

  • 每周期从当前 settings 热读 omni 配置的那个函数,除了原有的 model / base_url / api_key 再多刷一项自定义头,让 Web 上改完下一个窗口就生效(resolve_live_omni_config

  • 所有真正发 HTTP 的地方统一按同一种写法拼头:先写死 Authorization / Content-Type / User-Agent,再把自定义头展开在最后(omni.pyomni_client.py

  • 熔断器的探活链路同样接上,否则会出现"实际推理能通、探活一直失败"的割裂:拉模型列表、试聊天、试多模态三个探针都加参数,多模态探针失败回退到聊天探针时也把头带下去(probe.pyprobe_chatprobe_omni

    这份头的流向:

    config.json / settings.yaml  →  model.omni.extra_headers
              │
              ├─(校验器下推)→ perception.omni.extra_headers ─(每周期热读)→ OmniConfig.extra_headers
              │                                                                │
              │                        ┌───────────────────┬───────────────────┴──────────────┐
              │                        ↓                   ↓                                  ↓
              │                  单轮/批量/流式推理     fused 推理                        熔断探活
              │                  omni.py:328      omni_client.py:221/440      probe.py:93 / 221 / 375
              │
              └─(Web 管理端)→ omni_profiles[].extra_headers → 保存 / 激活 / 测试 / 拉模型列表 / 重试探测
    

主线 3 — Web 管理端的档案读写与探测都带上这份头

  • 后端返回配置时,无论是单个档案、当前生效档案还是档案列表,都多回一个自定义头字段(router.py
  • 保存配置的请求体新增同名字段,默认空字典,写进档案条目并用它做保存前的连通性探测(put_omni_config
  • 切换生效档案、点"测试连接"、点"拉取模型列表"、熔断后重试探测这四个入口都按同一套取值逻辑拿头:先按档案名在列表里找,找不到或找到的是空就退回当前生效档案的头(activatetestmodelsretry

关键设计原则

  1. 能力位默认给"支持",只让不支持的那家覆写。 基类写 supports_audio_input = True,GLM 的子类里写 False。这样新增任何 provider 都不需要动这条逻辑,且本 PR 对已有三家 provider 是零行为变更——回归面被压到只剩 GLM 一家。
  2. 适配器为空时按"能听"处理。 判断函数写成 adapter is None or adapter.supports_audio_input,让还没接上适配器参数的老调用路径保持改动前的行为,而不是悄悄退化成不发音频。属于向后兼容的兜底,代价是漏传参数不会报错(见 🔵 第 2 条)。
  3. 自定义头展开在保留头之后。 这是有意允许用户覆写默认头的取舍,方便对付要求改 User-Agent 的网关;副作用是也能覆写 Authorization(见 🔵 第 1 条)。
  4. 探测链路与推理链路共用同一份头。 四个管理端入口都取同一份配置,避免出现"测试连接绿灯、真跑推理 401"这种最难排查的割裂状态。
  5. 适配器做成无状态单例。 三个适配器实例在模块加载时建好,按模型名分发时直接返回,不做每次请求的实例化。

测试覆盖

主线 测试文件::类 用例摘要
1 test_provider.py::TestGlmAdapter 能力位为假、请求体与 MiMo 同构、按模型名分发命中 GLM、单例复用、OpenAI 系其余模型不受影响
1 test_prompt_builder.py::TestGlmAudioFallback 纯音频窗口在 GLM 上翻成视频路、has_audio / has_speech 被强制为假、payload 不含音频字段、非 GLM 仍走音频路
1 test_prompt_builder.py::test_audio_route_glm_drops_audio_block 组装 messages 时 GLM 不生成 input_audio
2 test_omni_client_circuit.py 自定义头进入实际请求头、空配置时不影响原有三个头
2 test_probe.py 三个探针分别把自定义头透传给底层 HTTP 客户端
2 test_omni_probe_tick_drive.py 探测 mock 的签名跟随新增参数更新

问题

本轮同时对账了上一版 review-pr-ci 的 8 条与 @xiaomi-miloco 维护者 2026-08-06 的人工 review。上一版 ci 提的 3 🔴 / 3 🟡 / 1 🔵 已全部修复(含 commit message 一条,历史重写后已对齐),不再重复列出。维护者那轮提的问题,经逐条核对至今没有任何修复 commit 落地,故在下面延续原严重度重新列出,并补充可复现的代码证据;标注「新增」的是本轮独立发现。

🔴 严重(提交前必须修复)

  • backend/miloco/src/miloco/perception/engine/omni/prompt_builder.py:698 — 音频→视频的路由翻转只翻了一半,非 fused 路径下 GLM 拿到的是自相矛盾的 prompt

    • 背景: 感知管线有两条组装 payload 的路。默认走 fused(身份识别合并进主调用),那条路的翻转是完整的——翻转后整个分支重走视频逻辑。另一条是非 fused 路径,在关掉身份引擎(identity_engine.enabled=false)或把 omni_call_mode 调成非 fused 时生效,单轮 / 批量 / 流式四个入口全部汇到 _build_payload。本 PR 专门为这条路加了翻转逻辑,也专门为它写了 TestGlmAudioFallback,所以它是本 PR 明确要支持的路径。

    • 问题: 翻转只发生在 _build_payload 的局部变量上,而组装 user 正文的函数拿不到这个结果——它自己又调了一次路由判断,这一次不带适配器参数,算出来仍然是音频路:

      # _build_payload:483-487 —— 这里翻了
      route = _resolve_route(packets)
      if route == "audio" and not _adapter_hears_audio(adapter):
          route = "video"
      ...
      # _build_payload:502-504 —— 但没把 route 传下去
      user_text = _build_user_content(
          packets, context, stream=stream, label_lookup=label_lookup,
      )                                          # ← 缺 route= / adapter=
      
      # _build_user_content:698 —— 于是这里重算,拿不到适配器,结果还是 "audio"
      is_video = _resolve_route(packets) == "video"

      结果是同一次请求里 system 和 user 互相打架:

      组装环节 实际用的 route 产出
      system prompt(:497-506 video(已翻转) 要求输出 caption,且挂上 matched_rules 字段
      user 正文的规则段与名册(:700-705 audio(重算,未翻转) 不渲染 # 待判断规则,不渲染设备/人物名册
      user 正文末尾的边界句(:711 audio 贴的是音频版:「(最高优先级):只判断本轮音频的直接聆听。」
      真正发出去的媒体(:514-523 video 只有静态帧,has_audio=False,一帧音频都没有

      在 GLM 上跑一个纯音频窗口(比如夜里只有说话声、画面没动),模型收到的最高优先级指令是"只判断本轮音频的直接聆听",而 payload 里根本没有音频——它要么返回空 caption,要么凭空编一段听觉描述,两种都是坏结果。更严重的是规则告警:matched_rules 的字段规范里写死了「该段为空则 matched_rules 输出 []」(field_registry.py:156),而 user 正文因为按音频路走、根本没渲染 # 待判断规则 段,于是这类窗口的规则匹配在 GLM 上恒为空数组、静默全灭,日志里看不出任何异常。

      TestGlmAudioFallback 的四个用例只断言了 route 值、has_audio / has_speech 和 payload 里有没有音频字段,没有断言 user 正文的内容,所以这个 bug 测试全绿。

    • 改进: 把翻转后的 route 显式传下去,不让下游重算。

      # prompt_builder.py:502 —— 调用处
          user_text = _build_user_content(
              packets, context, stream=stream, label_lookup=label_lookup,
              route=route,  # ← 传入已翻转的 route,杜绝下游重算漂移
          )
      # prompt_builder.py:688 —— 函数签名与首行
      def _build_user_content(
          packets: list[IdentityPacket],
          context: OmniContext,
          *,
          stream: bool = False,
          label_lookup: "dict[str, str] | None" = None,
          route: str | None = None,
      ) -> str:
          # 非 fused 兜底路径:单条 user 文本,规则 + 历史 + 本轮事实内联(fused 路径才把它们
          # 拆成独立 message)。规则用新「# 待判断规则」格式,与 fused 一致。
          # route 由调用方传入(已含 provider 能力降级的翻转结果);缺省时才自行判定。
          parts: list[str] = []
          is_video = (route or _resolve_route(packets)) == "video"

      并给 TestGlmAudioFallback 补一条正文断言,否则同类漂移还会再发生:

      def test_glm_audio_only_user_content_uses_video_boundary(self):
          payload = build_omni_prompt(audio_only_packet, ctx, adapter=GlmAdapter())
          assert payload["user_content"].endswith(_USER_REF_BOUNDARY)
          assert "# 待判断规则" in payload["user_content"]
  • backend/miloco/src/miloco/perception/engine/omni/prompt_builder.py:892-917 — fused 路径把图片块和视频块放进同一条消息,GLM 会稳定 400(维护者已实测 code 1214,本轮复核仍未修)

    • 背景: fused 是默认路径(identity_engine.enabled 默认 Trueomni_call_mode 默认 "fused",见 pipeline.py:325-329config.py:350-357),也就是说绝大多数用户接上 GLM 后走的就是这条路。这条路组装 user 消息时,会在主视频块之前依次塞入若干图片块。

    • 问题: 智谱侧不接受同一次请求里既有视频理解又有图片理解,维护者在本 PR 下已实测报 400 / code 1214 不支持同时进行视频理解和图片理解。而 fused 的正文组装里有三处会产出 image_url 块,全部排在视频块之前:

      # _build_fused_user_content:891-917
      # 4. gallery(候选成员参考图,紧邻 video 便于视觉比对)
      content.extend(gallery_content)                              # ← 已登记成员的参考图
      if has_pets:
          content.extend(build_pet_reference_content(...))         # ← 已登记宠物的多姿态参考图
      if ref_block is not None:
          content.append({"type": "text", "text": (...)})
          content.append(ref_block)                                # ← Smart Crop 的全景参考帧
      # 5. 主 video
      if video_b64 and len(video_b64) >= _MIN_VIDEO_B64_LEN:
          content.append(adapter.build_video_block(video_b64, media_info))

      这三条只要命中任意一条就撞 1214,而实际部署几乎必然命中:Smart Crop 的两道开关在打包默认配置里都是开的(settings.yaml:102-103crop_enhance.enabled: trueuser_enabled: true,两者同时为真才不短路返回,见 _maybe_encode_adaptive),而它恰恰是在"画面里裁得出活动区域"时才出图——那正是 fused 窗口被触发的典型场景。也就是说:用户按 README 配好 GLM、家里有人走动,第一个感知窗口就 400;只有在没登记任何成员和宠物、且 Smart Crop 那一轮没裁出区域时才会侥幸通过。

      这一项是本 PR 的接入可用性阻断,不是边角 case——PR 标题声称"支持 GLM-4.6V",但默认配置下主路径跑不通。

    • 改进: 沿用本 PR 已经建立的能力位模式,再加一个"能不能图视混发"的开关,并在 fused 正文组装的出口做一次不变量检查。

      # provider.py —— 基类新增,默认宽松,与 supports_audio_input 同一套写法
      class OmniProviderAdapter(ABC):
          supports_audio_input: bool = True
          # 是否允许同一条 user 消息里同时出现 image_url 与 video_url 块。
          # 智谱 GLM 侧会返回 400 / code 1214「不支持同时进行视频理解和图片理解」。
          supports_image_video_mix: bool = True
      # provider.py —— GlmAdapter 覆写
      class GlmAdapter(MiMoAdapter):
          supports_audio_input = False
          supports_image_video_mix = False
      # prompt_builder.py —— _build_fused_user_content 出口(第 925 行 _log_user_content 之前)
      if not adapter.supports_image_video_mix:
          has_video = any(b.get("type") == "video_url" for b in content)
          if has_video:
              dropped = [b for b in content if b.get("type") == "image_url"]
              if dropped:
                  logger.warning(
                      "event=fused_image_dropped_for_provider count=%d "
                      "(provider 不支持图视混发, 本窗口丢弃参考图保留视频)",
                      len(dropped),
                  )
                  content = [b for b in content if b.get("type") != "image_url"]

      取舍:上面这版保视频丢图片,代价是 GLM 上人脸/宠物比对和 Smart Crop 全景参考失效(识别退化为纯文字档案匹配),但窗口能跑通。另一种做法是保图片丢视频(把视频降级成抽帧图片),识别质量更好但丢失时序信息、且改动面大得多。无论选哪种,都需要把随图片块一起发的引导语(:906-909 的「下方第一张图为全景场景参考,随后的视频是…」)一并处理,否则会变成悬空指代。

  • backend/miloco/src/miloco/admin/router.py:1046-1052 — Web 端保存任意配置都会把自定义请求头清空,且目前没有任何界面能设置它

    • 背景: 自定义请求头是本 PR 的主线 2,GlmAdapter 的文档明确把用户指向它:「请通过配置的 model.omni.extra_headers 自行添加」(provider.py:183-185)。保存配置的请求体里,api_key 特意设计成 None = 沿用原值,防止前端把打码值写回去覆盖真 key。

    • 问题: 自定义头字段没有沿用同一套保护,而是用了 default_factory=dict——前端不传就是空字典,落库时无条件覆盖:

      # OmniConfigBody:985-993
      api_key: str | None = None   # 留空 = 沿用该档案原 key(不被打码值覆盖)   ← 有保护
      extra_headers: dict[str, str] = Field(
          default_factory=dict,     # ← 不传 = 空字典,没有"沿用原值"语义
          ...
      )
      
      # put_omni_config:1047-1052
      entry = {
          "label": label, "base_url": base_url, "model": model, "api_key": key,
          "extra_headers": dict(body.extra_headers or {}),   # ← 无条件覆盖
      }

      web/ 目录下对 extra_headers 的引用数为 0——前端根本没有这个输入框,任何一次保存请求都不会带这个字段。三条独立的坏后果:

      1. 配置静默丢失:用户按 docstring 手改 config.json 加好 X-Title,之后在 Web 上改个模型名点保存,头就没了,配置文件里那行被覆盖成 {}。用户视角看不到任何提示,只会在下一次推理时收到 429 余额不足。
      2. 保存前探测用的也是空头:1061-1063 把同一个空字典传给 probe_omni):真实推理会带头、探测不带头,两条链路的配置对不上,本 PR 主线 2 原则 4「探测链路与推理链路共用同一份头」在这个入口失效。
      3. 委托的能力兑不出来:docstring 把用户指向 model.omni.extra_headers,但三条通道全不通——Web 会清空(本条);CLI 的配置路径白名单里 model.omni 只登记了 model / base_url / api_key 三条(cli/src/miloco_cli/config.py:81-87),改这个路径直接报 unknown config path;README 与知识库也没提。最后只剩"手改 config.json 且此后再不用 Web 保存"这一条独木桥。
    • 改进: 与 api_key 对齐成"不传 = 沿用原值"。

      # router.py —— OmniConfigBody
          extra_headers: dict[str, str] | None = Field(
              default=None,
              description=(
                  "附加请求头(可选)。None = 沿用该档案原有的头,{} = 显式清空。"
                  "部分网关要求携带自定义头才走特定通道或计费口径。"
              ),
          )
      # router.py:1046 —— put_omni_config,落库与探测都改用合并后的值
          key = _key_by_label(orig or label, body.api_key, base_url=base_url)
          # extra_headers 与 api_key 同语义:None=沿用原档案值,{}=显式清空
          if body.extra_headers is None:
              prev = next((p for p in profiles if p["label"] == (orig or label)), None)
              headers = dict((prev or {}).get("extra_headers") or {})
          else:
              headers = dict(body.extra_headers)
          entry = {
              "label": label,
              "base_url": base_url,
              "model": model,
              "api_key": key,
              "extra_headers": headers,
          }
          ...
              result = await _probe.probe_omni(model, base_url, key, extra_headers=headers)

      另外建议本 PR 内一并补齐委托通道,至少二选一:① 在 CLI 的路径白名单里登记 model.omni.extra_headers;② 在前端配置页加一个 key-value 编辑区。否则这个特性对普通用户等于不存在。

🟡 重要(应当修复)

  • backend/miloco/src/miloco/admin/router.py:1244-1247 / :1323-1329 — 「测试连接」和「拉取模型列表」把自定义头发往请求体里的任意 base_url,缺少档案自身已有的防跨 URL 校验(新增)

    • 背景: 这个仓库对管理端已经有明确的威胁模型——保存配置时取旧 key 的函数专门做了 URL 校验,注释写得很清楚:「传 base_url 让 _key_by_label 校验"URL 未变才沿用旧 key",防跨 URL 复用凭证」(router.py:1045)。也就是说"拿到管理端权限后诱导服务端把凭证发去外部地址"是已被承认在范围内的风险。

    • 问题: 本 PR 新加的两个取头入口没有跟上这道护栏。两处代码完全相同:按请求体里的档案名去列表里找头,找不到就退回当前生效档案的头,然后连同请求体里未经任何校验的 base_url 一起发出去:

      # test_omni_config:1242-1247 与 list_omni_models:1321-1329 逻辑一字不差
      if label:
          for p in get_settings().model.omni_profiles:
              if p.label == label:
                  extra_headers = dict(p.extra_headers or {})
                  break
      if not extra_headers:
          extra_headers = dict(omni.extra_headers or {})     # ← 退回当前生效档案
      result = await _probe.probe_omni(
          model, base_url, api_key, extra_headers=extra_headers   # ← base_url 来自请求体,未校验
      )

      对比同文件的 _key_by_label——它会因为 URL 变了就拒绝沿用旧 key,这里却对头一路放行。具体坏行为:调用 /admin/omni/test,请求体填 base_url=https://attacker.example/v1label 留空或填一个不存在的名字,服务端就会把当前生效档案里的全部自定义头(现实中就是 X-Title 这类凭证性标识,也可能被用户放了别的 token)明文发到攻击者的地址上。/admin/omni/models 同理。这是本 PR 新引入的信息外发面,且与仓库自己已经写下的防护口径不对称。

    • 改进: 复用同一条判断——只有 base_url 与该档案记录的一致时才沿用它的头,抽成一个和 _key_by_label 并列的辅助函数,两个入口共用(顺带消除两处复制粘贴)。

      # router.py —— 放在 _key_by_label 附近
      def _headers_by_label(
          label: str | None, base_url: str
      ) -> dict[str, str]:
          """按档案名取自定义请求头,仅当 base_url 与该档案记录的一致时才沿用。
      
          与 _key_by_label 同一条防线:管理端可以指定任意 base_url 做探测,若无条件
          把已保存的头带过去,等于允许把凭证性标识(X-Title 等)外发到任意地址。
          URL 不匹配 / 档案不存在 → 返回空,让调用方自带头或不带头。
          """
          target = (base_url or "").strip().rstrip("/")
          for p in get_settings().model.omni_profiles:
              if label and p.label != label:
                  continue
              if (p.base_url or "").strip().rstrip("/") != target:
                  continue
              return dict(p.extra_headers or {})
          active = get_settings().model.omni
          if (active.base_url or "").strip().rstrip("/") == target:
              return dict(active.extra_headers or {})
          return {}
      # test_omni_config / list_omni_models 两处同样改成
      extra_headers = _headers_by_label((body.label or "").strip() or None, base_url)
  • backend/miloco/src/miloco/admin/router.py:1244 / :1323 — 用「空不空」判断要不要回退,把"这个档案本来就没有头"和"没找到这个档案"混成同一件事

    • 背景: 上一条那段取头逻辑,除了缺 URL 校验,回退条件本身也写得不对。这一条即使按上一条的建议加了 URL 校验也依然存在,所以单独列出。

    • 问题: 回退用的是 if not extra_headers,而不是"有没有找到档案"。一个档案的头本来就是空字典时,它会被当成"没找到",转而借用当前生效档案的头:

      if label:
          for p in get_settings().model.omni_profiles:
              if p.label == label:
                  extra_headers = dict(p.extra_headers or {})   # ← 命中,但可能是 {}
                  break
      if not extra_headers:                                      # ← {} 与"没命中"不可区分
          extra_headers = dict(omni.extra_headers or {})

      具体坏行为:用户有两个档案,A 是智谱 Coding Plan(头里有 X-Title)且当前生效,B 是自建兼容网关(不需要任何头)。用户选中 B 点「测试连接」,服务端会把 A 的 X-Title 借给 B 一起发出去。测试结果因此可能是假绿灯(B 的网关忽略未知头),而真正推理时切到 B 是不带这个头的——测试通过与实际行为不一致,正是本 PR 主线 2 原则 4 想避免的那种割裂;反过来,如果 B 的网关对未知头敏感,又会得到一个无法解释的红灯。

    • 改进: 用哨兵值区分"命中"和"没命中"(下面这段可以直接并进上一条建议的 _headers_by_label 里)。

          matched: dict[str, str] | None = None
          if label:
              for p in get_settings().model.omni_profiles:
                  if p.label == label:
                      matched = dict(p.extra_headers or {})   # 命中即定,哪怕是 {}
                      break
          # 只有"没指定档案名"或"档案名查无此人"才退回当前生效档案
          extra_headers = matched if matched is not None else dict(omni.extra_headers or {})
  • backend/miloco/src/miloco/perception/engine/omni/omni_client.py:166-180 — 只改自定义请求头不会清熔断,配置改对了也要继续熔断到自然恢复(维护者已提,未修)

    • 背景: omni 有熔断器,连续失败后进入打开状态,期间跳过调用。为了让用户"在 Web 上改对配置后立刻恢复",有一个检测配置变化就主动清熔断的钩子,每次热读配置时调用。

    • 问题: 这个钩子判断变化用的是 model / base_url / api_key 三元组,本 PR 新增的第四项没有加进去:

      def _maybe_reset_breaker_on_config_change(resolved: OmniConfig) -> None:
          """检测 (model, base_url, api_key) 三元组变化,变了就清熔断。..."""
          triple = (resolved.model, resolved.base_url, resolve_omni_api_key(resolved.api_key))
          #        ↑ 缺 extra_headers

      具体坏行为,正是本 PR 主要针对的那个场景:用户接智谱 Coding Plan,忘了配 X-Title,网关按普通资源包计费连续返回 429,熔断打开。用户读到报错、加上 X-Title 保存——model / base_url / api_key 一个都没变,钩子认为"配置没动",熔断不清,感知继续跳过 omni 调用,直到熔断器自己走完半开探测周期才恢复。用户视角是"我明明改对了,还是不工作"。

      同一处还有个连带的文档失真:热读函数的 docstring 仍写「每周期从当前 settings 刷新 model/base_url/api_key」(omni_client.py:137-163),实际已经多刷了一项。

    • 改进:

      def _maybe_reset_breaker_on_config_change(resolved: OmniConfig) -> None:
          """检测 (model, base_url, api_key, extra_headers) 变化,变了就清熔断。跨调用状态
          保存在函数属性 ``._last_triple`` 上——比 module-level global 更内聚。
      
          extra_headers 必须参与:智谱 Coding Plan 漏配 X-Title 会连续 429 打开熔断,
          用户补上头时 model/base_url/api_key 都没变,不纳入判定就清不掉熔断。
          """
          triple = (
              resolved.model,
              resolved.base_url,
              resolve_omni_api_key(resolved.api_key),
              tuple(sorted((resolved.extra_headers or {}).items())),  # dict 不可 hash,排序成元组
          )

      热读函数的 docstring 同步改成「刷新 model / base_url / api_key / extra_headers」。

  • backend/miloco/src/miloco/perception/engine/omni/provider.py:183-185 — 适配器 docstring 把「Coding Plan 需要 X-Title」写成了确定结论,但这个头名在智谱官方文档里查无实据(维护者已提,未修)

    • 背景: 新增适配器的 docstring 末段给用户指了一条具体配方:使用 Coding Plan 签发的 Key 需要携带 X-Title 这个 MCP 流量标识头,否则按普通资源包计费并返回 429。同样的表述也进了 OmniConfigBody 的字段说明(router.py:990-992)和 PR 描述。

    • 问题: 维护者在本 PR 下已说明,查了智谱五份官方文档,X-Title 零命中——这个头名更像是 OpenRouter 的约定(OpenRouter 用 HTTP-Referer / X-Title 做应用归因),被套到智谱身上了。坏后果是双向的:一方面仓库里落下一条无法验证的具体配方,用户照着配了不生效会怀疑是产品 bug 而不是配方错;另一方面这段文字会被后续读代码的人当成已验证的领域知识继续引用(docstring 是 IDE 悬浮提示会直接弹出来的内容)。

    • 改进: 除非能补上官方文档链接或抓包截图作为出处,否则把具体头名降级成中性描述,把"配什么头"交还给用户和他们的网关文档。

      # provider.py —— GlmAdapter docstring 末段
          某些网关签发的 Key 需要在请求里携带额外的标识头才走对计费通道或路由否则可能被按其它资源包计费或直接拒绝)——具体头名以所用网关的官方文档
          为准可通过配置项 ``model.omni.extra_headers`` 自行添加 adapter
          不做任何硬编码
      # router.py —— OmniConfigBody.extra_headers 的 description
              description=(
                  "附加请求头(可选)。None = 沿用该档案原有的头,{} = 显式清空。"
                  "部分网关要求携带自定义头才走特定通道或计费口径,具体头名见网关官方文档。"
              ),

      PR 描述里对应的那句也建议同步改掉——PR body 随时可编辑,成本极低。

🔵 建议(可选优化)

  • backend/miloco/src/miloco/perception/engine/omni/omni.py:324-328 — 自定义头展开在保留头之后,可以覆盖掉鉴权头

    • 背景: 九个发请求的地方都按同一个模式拼头:先写死三个必需头,再把用户配置展开在最后。

    • 问题: 字典展开是后写覆盖前写,所以用户配一个 {"Authorization": "..."} 就能顶掉真正的鉴权头,配 Content-Type 能顶掉 application/json。这不会造成安全问题(配置本来就是本机管理员写的),但排查成本很高——请求失败时日志里看到的是 401,而配置文件里 api_key 明明是对的,没人会想到去看 extra_headers。三个位置同款:omni.py:328omni_client.py:221:440probe.py:93 / :221 / :375

    • 改进: 如果覆盖是有意为之(比如要支持改 User-Agent 的网关),那就在字段说明里写明"可覆盖默认头,包括 Authorization";如果不是,加一层保护:

      # 建议抽成公共函数,九处共用
      _PROTECTED_HEADERS = {"authorization", "content-type"}
      
      def merge_extra_headers(
          base: dict[str, str], extra: dict[str, str] | None
      ) -> dict[str, str]:
          """合并用户自定义头,保护鉴权与协议头不被覆盖(大小写不敏感)。"""
          out = dict(base)
          for k, v in (extra or {}).items():
              if k.lower() in _PROTECTED_HEADERS:
                  logger.warning("event=extra_header_ignored key=%s (受保护头不可覆盖)", k)
                  continue
              out[k] = v
          return out
  • backend/miloco/src/miloco/perception/engine/omni/prompt_builder.py:172-197 — 查询用的组装函数漏传适配器,GLM 上会走成全景而非自适应分辨率(上一版 ci review 提过同类问题,此处仍未修)

    • 背景: 本 PR 给几乎所有组装 payload 的入口都补上了适配器参数,用来做能力判断和视频块构造。

    • 问题: build_query_prompt 这一个入口漏了——它调用编码函数时没有传适配器:

      # build_query_prompt:189-191
      video_b64, media_info = _encode_batch_video(
          identity_packets, short_edge=_effective_panorama_short_edge()
      )                                          # ← 缺 adapter=

      因为判断函数写的是 adapter is None or adapter.supports_audio_inputprompt_builder.py:1372),传 None 会被当成"能听",不会报错、不会告警,只是静默按默认适配器行为编码。当前这个入口不走音频路,所以还没有可观察的坏行为——但它是下一个同类 bug 的温床,且与本 PR 其余入口的写法不一致。唯一调用方在 pipeline.py:881

    • 改进:

      # prompt_builder.py —— build_query_prompt 内,与 build_fused_payload:240-244 同款解析
          if adapter is None:
              from miloco.config import get_settings
      
              from .provider import get_adapter as _get_adapter
              adapter = _get_adapter(get_settings().model.omni.model)
          video_b64, media_info = _encode_batch_video(
              identity_packets,
              short_edge=_effective_panorama_short_edge(),
              adapter=adapter,
          )
  • backend/miloco/src/miloco/perception/engine/omni/provider.py:513 — 按模型名分发时 "glm" in name 匹配过宽,会把不该管的 GLM 型号也划进来

    • 背景: 分发函数按模型名里的关键字挑适配器,qwen / gemini / glm 三条判断依次匹配,都不中就用默认的。

    • 问题: if "glm" in name 是子串匹配,会把整个 GLM 家族全部收进 GlmAdapter,而这个适配器的核心断言是"不支持音频输入"。至少三类误伤:

      1. glm-4-voice:智谱的语音线模型,恰恰是支持音频输入的那一个,被这条判断划走后会永久失去音频能力——纯音频窗口全部翻成视频路,功能反向退化。
      2. glm-4v-plus / glm-4v-flash:视觉线的其它型号,fps / media_resolution 这两个参数是否被接受并未验证(docstring 只声称与 MiMo 同构是基于 4.6V 实测的)。
      3. glm-4-plus 等纯文本型号:本来就不该进多模态适配器。
    • 改进: 收窄成显式的型号前缀白名单,未知型号退回默认适配器(保守方向:默认适配器不做任何能力降级,行为等同本 PR 之前)。

      # provider.py
      # GLM 家族只有视觉线(4.6v / 4v)需要走 GlmAdapter 的能力降级;
      # glm-4-voice 支持音频输入,glm-4-plus 等纯文本型号不该进多模态适配器,
      # 都走默认适配器(不降级 = 与本 PR 之前行为一致)。
      _GLM_VISION_PREFIXES = ("glm-4.6v", "glm-4v")
      
      def get_adapter(model: str) -> OmniProviderAdapter:
          ...
          if name.startswith(_GLM_VISION_PREFIXES):
              return _GLM_ADAPTER
          return _DEFAULT_ADAPTER
  • backend/miloco/src/miloco/config/settings.schema.json:81-103 — 新增配置项没有同步进 JSON schema

    • 背景: 仓库的开发规范要求配置项改动同步更新 schema(knowledge/06-dev-guide/dev-guide.md:76)。

    • 问题: schema 里 omni 段仍只有 model / base_url / api_key 三个属性,没有 extra_headers。因为该段 additionalProperties: true,运行时不会报错,坏行为只体现在编辑器体验上:在支持 schema 的编辑器里手改 config.json(这恰恰是目前唯一能配这个字段的方式,见 🔴 第 3 条)不会有补全和类型提示,写错类型(比如写成数组)要等到服务启动才发现。现有的 test_settings.py:87 只校验"schema 里的项都存在于设置类"这一个方向,反向缺失测不出来。

    • 改进:

      // settings.schema.json —— model.omni.properties 内追加
      "extra_headers": {
        "type": "object",
        "description": "附加 HTTP 请求头。部分网关要求携带自定义头才走特定通道或计费口径。",
        "additionalProperties": { "type": "string" },
        "default": {}
      }
  • backend/miloco/src/miloco/admin/router.py:936 / :950 / :962 / :975 — 自定义头在管理端接口里明文回显,而同一份数据里的 api_key 是打码的

    • 背景: 管理端返回 omni 配置时,api_keyapi_key_masked 打码,避免把明文凭证送进浏览器和前端日志。

    • 问题: 同一份返回里新增的自定义头是原样 dict(p.extra_headers or {}),不打码。而这个字段的官方用途本身就是装凭证性标识(PR 描述举的例子 X-Title 就是计费通道凭证,用户也可能往里放别的 token)。同一份 payload 里一个打码一个明文,口径不一致——凭证泄露面从"后端配置文件"扩大到"浏览器 devtools / 前端错误上报 / 截图"。

    • 改进: 与 api_key 对齐,值打码但保留 key 名(前端要显示"配了哪些头"):

      def _mask_headers(h: dict[str, str] | None) -> dict[str, str]:
          """头的 key 名照回(前端要显示配了哪些头),值打码——与 api_key_masked 同口径。"""
          return {k: (v[:2] + "***" if len(v) > 2 else "***") for k, v in (h or {}).items()}

      四处 dict(... .extra_headers or {}) 改成 _mask_headers(... .extra_headers),并配合 🔴 第 3 条的"不传 = 沿用原值"语义,前端就不会把打码值写回。

  • backend/miloco/src/miloco/perception/engine/omni/provider.py:177-179 — docstring 声称与 MiMo 请求体完全同构,但其中两个参数在智谱侧未经证实

    • 背景: 新适配器继承 MiMo 的实现,docstring 写「请求体与 MiMoAdapter 一致(video_url + fps + media_resolutionthinking:disabled 实测兼容)」。

    • 问题: 维护者在本 PR 下反馈,fpsmedia_resolution 在智谱官方文档里没有记载,且实测带与不带这两个参数返回的 prompt_tokens 完全相同——也就是说智谱侧大概率是忽略了它们,而非"兼容"。坏后果是维护期的误判:后续有人要调低 token 消耗,会先去动 media_resolution,因为 docstring 说这是生效的;调完发现毫无变化,再花时间排查。

    • 改进: 把已验证和未验证的部分分开写,别混成一句"实测兼容"。

          鉴权与请求体沿用 MiMoAdapter(video_url  + thinking:disabled 实测兼容)。
          :``fps`` / ``media_resolution`` 两个参数在智谱官方文档中无记载,实测带与
          不带 prompt_tokens 完全相同,判断为服务端忽略——保留只为与 MiMo 共用同一份
          构造代码,不要依赖它们在 GLM 上调节采样率或分辨率
  • 待讨论 — 纯音频窗口在听不见的模型上"翻成视频"是否划算,还是该直接跳过这一窗口

    • 背景: 维护者在本 PR 下提出了这个取舍问题,作者尚未回复,我倾向于认为它值得在合并前有个明确结论,故记在这里。

    • 问题: 翻成视频路的代价是维护者实测出来的:同一个窗口从 38 个 prompt token 涨到 670 个,17.6 倍。而这类窗口的定义就是"画面没有变化"——送过去的是一批几乎静止的帧,模型能从中得到的新信息接近于零,产出多半是"画面无变化"这类空转 caption。管线本来就有跳过窗口的先例:闸门那一层在没有任何信号通过时就直接不调用模型。

    • 改进: 两个方向都合理,选哪个是产品取舍,建议作者明确表态并把理由写进 docstring:

      # 方向 A(当前实现):翻成视频路,保留"至少有画面证据"的兜底
      # 代价:纯音频窗口 token 涨约 17.6x(38 → 670),产出多为空转 caption
      
      # 方向 B:直接跳过该窗口,与 gate 无信号时的处理对齐
      if route == "audio" and not _adapter_hears_audio(adapter):
          # 听不见音频的 provider 遇到纯音频窗口:画面本就无变化,送静态帧
          # 换不来新信息(实测 prompt_token 38 → 670),与 gate 无信号时一致直接跳过。
          raise SkipWindow("provider 不支持音频输入且本窗口无画面变化")

结论

需要修改 — 三个 🔴 里,图视混发那条会让默认配置下的 GLM 接入在第一个有人活动的窗口就 400,直接阻断本 PR 声称的能力;路由翻转半截导致非 fused 路径下规则告警在 GLM 上静默全灭;Web 保存清空自定义头则让主线 2 的整条配置通道在实际使用中不成立(且目前 Web / CLI / 文档三条路都配不了这个字段,建议本 PR 内至少打通一条)。四个 🟡 中「测试连接 / 拉取模型列表把头发往任意 base_url」是本 PR 新引入的外发面,与仓库自己在 _key_by_label 里写下的防护口径不对称,建议一并处理。上一版 review-pr-ci 提的 8 条已全部修复,主线 2 的九个发请求位置逐一核对无遗漏,fused 路径的适配器透传与宠物识别路径均无问题。


由 review-pr skill v1.6 生成

pr-review 发现的问题修复:

🔴 GLM-4.6V 是纯视觉模型(文本/图片/视频/文件), input_audio 属于
   GLM-4-Voice/GLM-Realtime 线, 发给 4.6V 稳定 400 → 夜间"有声无画面
   变化"窗口会把熔断打进需人工干预的 CONFIG 态:
   - OmniProviderAdapter 新增 supports_audio_input 能力位(默认 True)
   - GlmAdapter 置 False
   - prompt_builder audio route / omni_client on_demand 路径按能力位
     降级为 text-only, 不发音频块

🟡 probe.fetch_models / probe_omni 预检硬编码 Bearer, 漏掉 GLM 的
   X-Title 头(429 → 模型下拉列表拉不出来): 按 hostname 判断补头,
   与 adapter.auth_headers 保持一致

🔵 注释/文档: 3 处 OpenAI 兼容族枚举补 GLM; singleton 测试改用
   glm-4.6v-flash 覆盖同族复用; PR 描述改子串匹配口径

实测: GLM-4.6V 拒 m4a(400 format 不支持), 尝试 wav 也报 base64 格式
错误, 音频输入不可用 → 降级是正确方案
pr-review 第二轮意见修复:

🔴 GLM Coding Plan 的 X-Title 头是计费口径/服务条款层面的选择,
   不应由 adapter 硬编码且无法关闭:
   - OmniModelSettings / OmniConfig 新增 extra_headers 字段(默认空)
   - GlmAdapter 移除 auth_headers 覆写, 退回纯继承(仅保留
     supports_audio_input = False)
   - omni.py / omni_client.py 三处请求头组装合并 config.extra_headers
   - probe.py 移除硬编码 X-Title, 还原纯净实现

🟡 supports_audio_input 只挡了音频块, 没挡 prompt 里"本轮有音频"
   的声明 — GLM 每个 audio-only 窗口会收到"请转录音频"的 system
   prompt 却没有音频(空烧钱 + 可能脑补 speeches):
   - 新增 _adapter_hears_audio() 助手
   - audio 路由对听不见音频的 provider 退回 video 路由(帧还在)
   - video 路由 has_audio/has_speech 按听力置 False, 既有机制剥掉
     speeches/env_sounds
   - build_prompt 系列 / run_omni 系列透传 adapter

🔵 基类 docstring 修正; 新增 extra_headers 生效/默认测试
   测试: omni + probe + config 398 个全过
@github-actions github-actions Bot added size/L and removed size/M labels Jul 31, 2026
_propagate_model_omni_to_perception 下推时漏了 extra_headers 字段,
OmniConfig 拿不到用户配置的附加请求头(X-Title 等)
pr-review 第三轮意见修复:

🔴 探测链路(probe.py)没接 extra_headers — Coding Plan key 的探测
   请求 100% 429, 导致: 配置存不进去 / 模型下拉为空 / 熔断后自愈
   永久失效 / 手动重试也救不回来:
   - fetch_models / probe_chat / probe_omni 各加 extra_headers 参数
   - router.py 5 处调用点传入(OmniConfigBody 新增 extra_headers 字段,
     按 label 查档案 extra_headers, 缺省用当前 active)

🟡 resolve_live_omni_config 热更新漏 extra_headers — 改配置后
   请求头不生效, 需重启进程:
   - replace() 补 extra_headers=dict(o.extra_headers)

🟡 音频路降级零测试覆盖 — 新增 TestGlmAudioFallback 4 用例:
   audio-only 退回 video 路由 / MiMo 保持音频路 / fused 路径降级 /
   video 路由 GLM 剥掉 speeches 字段

🔵 注释口径修正(降级 text-only → 退回 video 路由) + get_adapter
   docstring 修正(鉴权头相同, 唯一差异是 supports_audio_input)

🔵 mp4 音轨编码与 has_audio 同判据 — _packet_audio_included /
   _encode_video / _encode_batch_video 传 adapter, GLM 视频路不混
   音轨, 避免白传模型解码不了的 AAC

测试: omni + config 相关 395 个全过
pr-review 第四轮意见修复(同一字段的遗漏点):

🔴 probe_omni 内部 chat 回退路径漏传 extra_headers — GET /models
   通过后最后一跳 probe_chat 不带 X-Title → 429, 保存/激活/测试/
   重试全失败
🔴 processor._run_omni_probe 熔断自动探活漏传 — GLM 感知一次
   抖动后熔断半开探活永远 429, 永久停摆
🔴 activate_omni_config 写回 active 漏 extra_headers — 激活 GLM
   档案后推理立即 429

🟡 _profiles_as_dicts 不返回 extra_headers — DELETE 清零所有
   剩余档案的自定义头
🟡 _full_omni_payload 不返回 — 前端编辑无法回显, 保存即丢

测试: omni + admin 434 个全过
processor._run_omni_probe 现在传 extra_headers, 测试 mock 需同步
pr-review 第五轮意见:

🟡 test_omni_config 的 extra_headers 取法与 list_omni_models 不一致
   — 测试非 active 的 GLM 档案时 429, 统一按 label 查档案/缺省 active

🔵 probe.py extra_headers 路径零测试 — 新增 3 用例锁住 5 处合并点
   (fetch_models / probe_chat / probe_omni 两跳), 防重构静默回归
@Molly-3000

Copy link
Copy Markdown
Collaborator

hi please resolve conflicts :)

@Molly-3000

Molly-3000 commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

感谢 PR!

「新增 provider」和「provider 能力差异」两件事分开处理、能力位默认放行让三家老 provider 行为零变化、extra_headers 从 config 一路穿到探测与熔断自愈(9 个组装点逐个核过没漏),完成度挺高的。

验证情况:在 PR head 7d99692(基线 main@1495fac)上,omni / probe / admin 相关 313 个测试全通过;tests/perception 有 2 个失败,但在同一基线的 origin/main 上一样失败,与本 PR 无关。

另外我用真实智谱 key 对 glm-4.6vglm-4.6v-flash 做了带对照组的实测(2026-08-05),下面标「实测」的都来自那组调用。

有一条阻塞问题,另有两处疑似 bug、一条文档建议、一个想和你讨论的取舍,最后是几条小事。

🔴 GLM 不接受图 + 视频同请求,而 fused 路径就是这么发的

实测(glm-4.6vglm-4.6v-flash 行为一致):

HTTP 400  {"error":{"code":"1214","message":"不支持同时进行视频理解和图片理解"}}

复现——只发图或只发视频都正常 200,混发才拒,调整块顺序也无效:

curl -s https://open.bigmodel.cn/api/paas/v4/chat/completions \
  -H "Authorization: Bearer $ZHIPU_KEY" -H "Content-Type: application/json" \
  -d '{"model":"glm-4.6v","messages":[{"role":"user","content":[
        {"type":"image_url","image_url":{"url":"data:image/png;base64,<小图>"}},
        {"type":"video_url","video_url":{"url":"data:video/mp4;base64,<小视频>"}}]}]}'

omni_call_mode 默认就是 "fused"perception/engine/config.py:309),而 fused 会在 video_url 之前放图片块。这里想按基线分开说一下,因为影响面差别挺大:

  • 在你这个 PR 的基线(main@1495fac 一带)上:图片块只有 gallery 参考图(prompt_builder.py:746 + :753),gallery 为空就走纯文本分支。所以只有库里有 ≥1 个注册成员时才混发——你之前实测能跑通是正常的,不是实测做得不对。
  • 在当前 main:又多了两处图片块,其中一处默认开着——
    • Smart Crop(UI 里的「智能裁切增强」)的全景参考帧_maybe_encode_adaptive 会把一张全景 JPEG 放在裁切视频之前("全景图在前、活动区域放大视频在后")。settings.yamlcrop_enhance.enableduser_enabled 双闸默认都是 true,而且它和有没有注册成员无关
    • 已登记宠物的多姿态参考图(build_pet_reference_contenthas_pets 时)。

所以等这个 PR rebase 到当前 main 之后,只要走 fused,每个窗口都会被拒,跟库里有没有人无关了。main 现在比 PR 基线前进了 143 个提交、本来也需要同步一下,所以我倾向按 rebase 后的行为来看这件事。

只有 fused 会混,其他路径不会:两条非 fused 主路径的 crops 是硬编码空列表(_build_payload:406build_query_prompt:171),tier_c 同人校验那条有 2 张图但不带 video(:1569)。

这个混发结构不是本 PR 引入的——MiMo / Qwen 走同一段代码,它们接受混发所以一直没暴露。只是把 GLM 接上这条链路正好是本 PR 做的事,所以想借这个 PR 一起把它收掉。

建议的方向:加一个模型能力位,把相关功能按能力关掉

从上面能看出来,注入图片块的地方现在有三处,分属两个独立开关的功能(identity/fused、Smart Crop)外加宠物参考图。所以这不是"改一处组装"能收掉的问题,而是**「模型能力 × 功能开关」的兼容性约束**——而且 Smart Crop 对 GLM 也没法「降级保留」:它的价值就是「全景图给上下文 + 裁切视频给细节」这个配对,把全景图去掉,裁切就失去了安全网。所以对不支持混发的模型,这些功能只能直接关掉、并且不允许开启,降级保留没有意义。

好消息是这个模式仓库里已经有了:smart_crop_available 就是"开关能不能点"——available=false 时前端置灰并给提示,避免"开关开着但后端不裁"(admin/router.py:1541-1548)。所以要做的是把判据从「发版级开关」扩成「发版级开关 AND 模型能力」。

具体建议:

  1. 加能力位 supports_image_video_mix: bool,基类默认 TrueGlmAdapterFalse——与现有 supports_audio_input 完全同构。docstring 里建议写明依据(glm-4.6v 实测返回 1214,日期),后来人才知道这是测出来的不是猜的。
  2. 在 payload 出口设一条不变量:adapter 不支持混发、而 payload 里同时存在 image 与 video 块时,按策略丢一边 + warn(或拒绝这次调用并记明原因)。这一层是兜底而不是 UX:本地部署下用户对配置文件有全权(Smart Crop 的 docstring 自己写了"拦不住"),手改 config.json 仍会走到这里;而且以后新增任何往 fused 塞图的功能,即使忘了这条约束也不会把 GLM 打挂。

这两条是我觉得本 PR 内比较值得一起处理的最小集,做完 GLM 就不会再 400 了。下面几条属于 UX 层、跨 backend/admin/web 三层,可以放到后续单独 PR,我们也可以接手,你不用在这个 PR 里扛

  1. available 的判据加上模型能力,并在旁边带一个 reasonrelease_gate / config_invalid / model_unsupported),前端按 reason 出不同提示。这里有个仓库已经踩过的坑:smart_crop_available 的注释自己写着,配置校验不过时 available 也是 false,但提示仍是"服务端尚未开放"、"此时那句提示归因是偏的"。再加一个原因就成了三个原因共用一句文案,用户容易顺着错的归因去排查。
  2. 这里建议只改 availableuser_enabled 保持不动:用户在 MiMo 下开着 Smart Crop、切到 GLM 应该置灰停用,切回 MiMo 要能自动恢复;如果连 user_enabled 一起改掉,用户原来的设置就静默丢了。现有的双闸语义正好支持这点。
  3. 两类开关的时效不一样,得分别处理:Smart Crop 走 crop_enhance_config_from_settings() 每窗口热读,换模型下个窗口就生效;而 identity_engine.enabledPerceptionConfig 里、建引擎时固化CropEnhanceConfig 的 docstring 明确说它故意不注册进 PerceptionConfig 就是为了避免"建引擎时缓存、失去热更新"),所以它没法立刻关闭,只能在保存配置时就拒绝这个组合、或者置灰并提示需重启。
  4. 如果能在模型选择处就标注 GLM 的这个限制会更好,避免等用户选完才发现开关是灰的。

顺带一个信息:identity_engine.enabled = Falseuse_fused 为 False,build_fused_payload 不会被调用(Smart Crop 的注入点也在它里面),所以 GLM 现在就能正常用,代价是没有身份识别。这也是上面「关掉而非降级」的现实版本。

🔴 Web 保存 omni 配置会清空 extra_headers

OmniConfigBody.extra_headersdict[str, str] = Field(default_factory=dict),没有 | None、也没有「留空 = 沿用原值」语义,而 put_omni_config 用它无条件重建整条 entry(admin/router.py:636-642)——同一个 body 里 api_key 是有这个语义的(_key_by_label)。

前端还完全没接这个字段(web/ 全文无 extra_headers),而且 onSave 固定 activate: falseUsageOmniConfig.tsx:317),非 active 档案连 preflight 都不跑,所以用户手配的头被清掉时得不到任何反馈;等下次真正启用这个档案,网关直接报错而配置里的头已经没了。

建议改成 extra_headers: dict[str, str] | None = NoneNone 时沿用原 entry 的值,与 api_key 保持一致。

🔴 route 翻转不完整,GLM audio-only 窗口的 prompt 自相矛盾

audio→video 的翻转只改了外层,prompt_builder.py:593 又用 _resolve_route(packets) 重算了一次未翻转的 route。结果同一次调用里:system prompt 声称要做规则判断、并要求输出 matched_rules,而 user content 里其实没有「# 待判断规则」段——等于让模型去判断一组它没收到的规则。

(如果下面那条取舍最后决定直接跳过 audio-only 窗口,这条自然消失。)

🟠 建议把 X-Title 那份配方从 docstring / PR 正文里去掉

先说边界:机制本身没问题,extra_headers 应该保留,这条只针对文案。

查了智谱五份官方文档(对话补全 API 参考、GLM-4.6V 模型页、Coding Plan FAQ、适用工具页、视觉 MCP 页),X-Title 零命中。官方对"买了套餐仍报余额不足"给的原因不是缺这个头,而是「套餐仅限官方支持的指定工具与产品环境中使用」+ 必须用指定 Base URL;适用工具页还明写「不得将订阅权益用于以下范围之外的工具或场景」。

这大概是你本地跑通时的实际配置,写出来本身是好意,也确实帮后来人省事。我们这边的顾虑主要在传播范围上:写进仓库 docstring 和 PR 正文之后,就相当于项目在向所有用户推荐这个用法,而万一有影响是落在用户账号上的。个人怎么用完全是个人选择,这条只是针对仓库文档。

provider.py:183-185 建议改成这样(文案你随意调整):

部分网关或套餐会要求额外的自定义请求头才能正确路由 / 计费;本 adapter 不做
硬编码,需要时通过 ``model.omni.extra_headers`` 配置,具体头名与取值以所用
服务商的官方文档为准。

这样字段存在的理由和用法指引都还在,只是不写具体的头名和取值。PR 正文那段也建议一并去掉——正文偏部署说明,不像 docstring 那样需要解释字段为什么存在。

顺带帮你补上 PR 里标记为未验证的一点:普通 API Key(资源包 / 按量)调 /api/paas/v4 的鉴权要求只有 Authorization + Content-Type,不需要附加标识头,所以默认空是正确的默认值。

🟡 想讨论:audio-only 窗口退回 video 值不值

gate.run_gatenot any_pass and not hold_active 时直接不产 packet(gate/gate.py:97),也就是「视觉无变化 + 无声音」的窗口,仓库既有做法是免费丢弃。而 _is_audio_only 已经排除了 hold=True,所以能进 audio 路由的窗口语义是「视觉 gate 判定无变化,且不在滞回期内」;GLM 走到这里,唯一的新信号(声音)又被 supports_audio_input=False 丢掉。

信息上它就比较接近那类被免费丢弃的窗口,而现在的处理是把它升格成整段 mp4 编码 + 上传 + 视频 token,是这条链路里最贵的调用形态。实测的量级:同一 prompt 纯文本 38 tokens,加一段 8 帧 / 320×240 的视频变 670,17.6×——这已经是能造出来的最小视频,生产窗口的帧数和分辨率都更高。而「夜里有声音、画面静止」恰好是 audio-only 最典型的场景。

另一面我也承认:visual_changed 是阈值判断而不是逐像素相同,hold 到期后有人静坐不动、上一条 caption 又隔得久,重发画面并非严格零增量。所以这是取舍不是 bug。我的倾向是在派发前判 _is_audio_only(packets) and not adapter.supports_audio_input → 跳过该窗口并打一条 log,与 gate 的 not any_pass 同构;也想听你怎么看。

几条小事

  • build_query_prompt 漏传 adapter=prompt_builder.py:165_encode_batch_video(...) 少了这个参数(其余 4 个 builder 和 build_fused_payload 都传了),主动查询仍送带音轨的 mp4。实测不会 400,而且音轨零 token(带与不带音轨 prompt_tokens 都是 670),所以代价只是白传字节——本例音轨让请求体涨了 6 倍。补上 adapter=adapter 即可。
  • fps / media_resolution 在 GLM 未文档化:官方视频块 schema 只允许 type + video_urladditionalProperties: false。实测不报错也不生效——带与不带这两个字段的 prompt_tokens 完全相同,若 media_resolution: "max" 真生效,视频 token 必然会变。docstring 里「与 MiMoAdapter 一致」建议补一句说明,否则后来人排查「GLM 效果不如 MiMo」时容易找错方向。视频走 base64 同属实测可用但未文档化。
  • extra_headers 变化不清熔断_maybe_reset_breaker_on_config_change 只看 (model, base_url, api_key),而这个字段恰恰是用来解决 429 的,用户改完还得等退避。resolve_live_omni_config 的 docstring 也已经失真。
  • extra_headers 合并在保留头之后{**base, **extra} 让用户能静默覆盖 Authorization / Content-Type / User-Agent,建议对这几个加保护或至少 warn。
  • CLI 设不了 extra_headerscli/src/miloco_cli/config.py_SCHEMA_PATHSmodel.omni.* 只有 3 项,set_values:345_apply_env_overrides:291 共用这份白名单;docs/ 和 README 也没提这个字段。加上 Web 会清空(见上面那条),目前只有手改 config.json 一条可用通道。
  • "glm" in name 匹配偏宽provider.py:513):按官方文档会捞进 glm-4-voice(唯一支持 input_audio 的 GLM,却会被标成不支持,和 docstring 自己的论据正相反)、glm-4v-plus-0111(要求 video 块在首位)、glm-4v-flash(限 1 张图且不支持 base64)。不阻塞,但建议收紧到已验证的视觉线。

如果方便,希望在本 PR 内一起处理的:混发那条的第 1、2 步(能力位 + 出口不变量)、Web 清空 extra_headers、route 翻转、X-Title 文案,以及最后那几条小事。另外麻烦把基线同步到当前 main 再重跑一遍测试。

完全可以留到后续 PR 的:混发那条的第 3-6 步(available / reason、前端置灰、模型选择处标注)。这几条跨 backend/admin/web 三层,还要动 Smart Crop 现有的双闸语义,工作量不小——你不想在这个 PR 里一起做的话,我们接手也完全没问题。

还想听你意见的:audio-only 窗口那条取舍。

最后把实测的边界说清:素材是 ffmpeg 现场合成的 2 秒 320×240 无音轨 mp4 加一张 PNG,没有用任何真实录像;覆盖 glm-4.6vglm-4.6v-flash,其他 GLM 型号没测(最后一条里那三个型号的结论来自官方文档而非实测)。如果哪条你实测到不一样的结果,贴出来就好,我按你的数据改结论。

@Molly-3000
Molly-3000 self-requested a review August 6, 2026 10:49
# Conflicts:
#	backend/miloco/src/miloco/perception/engine/omni/prompt_builder.py
#	backend/miloco/tests/perception/engine/omni/test_prompt_builder.py
Copilot AI lite review requested due to automatic review settings August 11, 2026 03:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

本 PR 为 Miloco 感知引擎的 omni 模型新增智谱 GLM-4.6V(OpenAI 兼容协议)接入,并围绕 GLM “不支持音频输入” 的限制引入路由级音频降级,同时增加 extra_headers 配置能力以支持 GLM Coding Plan 的 X-Title 头透传,确保探测链路与推理链路一致。

Changes:

  • 新增 GlmAdapter(继承 MiMoAdapter)并在 get_adapter() 中基于模型名子串路由到 GLM。
  • 增加 provider 能力位 supports_audio_input,对不支持音频的 provider(GLM)在 audio-only 窗口回退到 video 路由,并在消息组装层避免发送 input_audio
  • OmniModelSettings/OmniConfig 增加 extra_headers,并在探测与推理请求头组装处统一合并透传(默认不携带)。

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
backend/miloco/src/miloco/perception/engine/omni/provider.py 新增 GlmAdapter 与 GLM 路由,并通过能力位标记不支持音频输入
backend/miloco/src/miloco/perception/engine/omni/prompt_builder.py 基于 adapter 能力实现 audio-only → video 回退,并同步剥离音频相关 schema/提示与 mp4 音轨合成
backend/miloco/src/miloco/perception/engine/omni/omni_client.py 推理请求头合并 config.extra_headers;消息构建层按 supports_audio_input 兜底不发音频块;live config 透传 extra_headers
backend/miloco/src/miloco/perception/engine/omni/omni.py run_omni* 构建 prompt 时传入 adapter;请求头合并 extra_headers
backend/miloco/src/miloco/perception/engine/omni/probe.py probe/fetch_models/probe_chat/probe_omni 全链路透传 extra_headers
backend/miloco/src/miloco/perception/processor.py probe 调用透传 extra_headers 以匹配实际推理配置
backend/miloco/src/miloco/perception/engine/config.py OmniConfig 增加 extra_headers 配置字段
backend/miloco/src/miloco/config/settings.py OmniModelSettings 增加 extra_headers 并下推到 perception.engine.omni
backend/miloco/src/miloco/admin/router.py 管理端配置/激活/探测/拉模型列表接口透传并回显 extra_headers
backend/miloco/tests/perception/engine/omni/test_provider.py 覆盖 GLM 路由、OpenAICompat 归属、单例、鉴权头与音频能力位
backend/miloco/tests/perception/engine/omni/test_prompt_builder.py 覆盖 GLM 音频降级行为(audio-only 回退 video、fused 同步降级、prompt 剥离音频字段)
backend/miloco/tests/perception/engine/omni/test_probe.py 覆盖 probe/fetch_models/probe_chat/probe_omni 的 extra_headers 透传
backend/miloco/tests/perception/engine/omni/test_omni_client_circuit.py 覆盖 call_omni 头合并与 resolve_live_config 的 extra_headers 行为
backend/miloco/tests/perception/test_omni_probe_tick_drive.py 更新 tick-drive probe 桩函数签名与 fake settings 以支持 extra_headers

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 35 to +39
class _FakeOmni:
model = "m"
base_url = "https://x/v1"
api_key = "sk-x"
extra_headers: dict[str, str] = {}
Comment on lines 287 to +291
class _Mo:
model = "m1"
base_url = "https://x/v1"
api_key = "sk-NEW"
extra_headers: dict[str, str] = {}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants