Files
deepseek-harness/.agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.zh.md
Yichen Jiang 94289d057e fix(llm-pi-ai): let a model declare the request modalities it accepts
A model the installed pi-ai catalog does not describe was reported as
text-only with no way to say otherwise, so a vision model added through
the custom-provider form was refused at every image admission point.
The justification in the source described the DeepSeek chat-completions
serializer, which does reject image blocks; the pi-ai request converter
and every wire protocol it speaks carry images.

Modalities now resolve entry `input` -> installed catalog entry -> route
`defaultInput`, the chain the two capacity fallbacks already use, so the
route value is a fallback and never narrows a catalog model. Its default
stays `[text]`: nothing can interrogate a gateway for its modalities, and
over-claiming admits an image the provider rejects mid-turn, after prompt
admission has already committed the message.
2026-08-12 16:18:52 +08:00

9.2 KiB
Raw Blame History

Agent Notepi-ai 模型自行声明输入模态,未声明即为文本

Status: implemented

English | 中文

问题

settings.yaml 里没有任何写法能把一个手写的 pi-ai 模型描述成接受图片,而适配器对已安装 pi-ai catalog 未描述的每个模型都假定纯文本。部署通过 Web UI 的“添加自定义提供商”卡片新增的模型统统属于这一类,因此一个提供视觉模型的 OpenAI 兼容网关,无论实际提供什么,报出来的都是 inputModalities: ['text']

Harness 把缺失的模态当作否定能力并有三个准入点在构造任何请求之前就依据它行动模型选择拒绝切换进已含图片的会话prompt 准入拒绝图片,read_image 拒绝读取。它们的诊断让用户去选一个支持图片的模型——这条建议没有可达的指向对象,因为没有任何配置键能让手写模型变得支持图片。这条路封死在元数据上而非能力上:请求转换器和每种 pi-ai 线路协议都能携带图片,llm-pi-ai 自身的流前置检查才是唯一会拦下它的东西。

源码中把这一假定论证为适配器的真实能力而非部署选择。该论证描述的是 DeepSeek chat-completions 适配器——它的序列化器确实拒绝 image block——对 pi-ai 路由从来就不成立。

决策

模态的解析顺序是:条目的 input → 已安装 catalog 条目 → 路由的 defaultInput,后者自身默认为 [text] 这与 contextWindowmaxTokens 已经在用的链条逐字段一致。pi-ai 把 Model.input 定义为必填且按模型给出,因此条目字段直接镜像上游:一条路由可以让视觉模型与纯文本模型并存,而 override 可以修正那些网关实际提供的模态与 catalog 记录不符的模型。路由字段则让那些未被 catalog 描述的模型全都接受图片的网关,不必在每个条目上重复一遍。

路由值是回退值而非覆盖值——catalog 的优先级更高。 这采用的是 default* 的顺序而非 compat 的,两者不可互换:compat 之所以盖住 catalog是因为路由级的协议改指会整体作废 catalog 关于推理分派的事实;而模态是按模型的属性,对 catalog 自己出货的那些模型,它记录得准确无误。让路由值获胜就意味着 defaultInput: [text] 会悄悄剥掉该路由上每一个 catalog 视觉模型的图片能力——一个没有对应收益的坑,因为收窄其中某个模型正是该模型自己的 input 要做的事。

未声明即 [text],而这是「尚未声明」,不是对端点的猜测。 没有任何环节能去询问网关的模态——没有任何 OpenAI 兼容的列表端点会报告它们——因此唯一诚实的底线是每个受支持协议都确定携带的那个模态。这也正是模态回退值与容量回退值分道扬镳之处262,144 只是个说得过去的数字,且两个方向都会错(网关只给 8k 会溢出,给 1M 则被浪费),而 text 在一个方向上是安全的。两种猜错的代价同样并不对等。少声明会在图片被附加之前就拒绝并点名该模型,补救办法是一行有文档可依的配置。多声明会接纳一张图片、再由提供方在轮次中途拒绝——而此时 prompt 准入早已把消息持久化提交,于是会话会不断重发一个不可能成功的请求,且模型选择拒绝切换到任何纯文本模型。在最早可解析点付出一次廉价的拒绝,胜过在最晚点付出一次昂贵的。

条目的空列表与缺省同义;路由的空列表则被拒绝。 [] 描述的是一个什么都不接受、无法服务任何请求的模型,因此不作答,解析继续往下走。这个读法不是修辞:配置 schema 会为缺省数组物化出 [],把它当作“什么都不接受”,会悄悄剥掉 models 列表恰好点到的每一个 catalog 视觉模型的图片能力。而路由值下面没有可以代为作答的层级,因此它的空列表在写入处即被拒绝。路由的 models 列表出于同样的理由,早已用同一种方式解析缺省与空。

没有任何配置界面编辑 input 它和 compatreasoningEffortsthinkingBudgetsheaders 一样是 settings 文档字段,而模型列表编辑器仍是一张只覆盖 id、名称和两个容量的手写表单。这不会带来持久代价因为那张卡片本来就是按“承载自己并不编辑的字段”建造的它的行 patch 会先展开已存储的行再应用改动,而采纳候选时已有行优先于重新发现的候选,因此手写的 input 在两条路径上都能存活。

DeepSeek chat-completions 适配器保持不动。它的 ['text'] 是关于其序列化器的事实,而不是一处缺失的声明,它继续在发送前拒绝。

备选方案

  • 乐观的 [text, image] 默认值 —— 让触发本次变更的场景零配置即可工作;而且网页表单不会写入任何模态,因此保守默认值会把补救办法留在一个纯 Web 用户没有理由打开的文件里。被否决的理由是猜错时的严重程度:被拒绝的附件是一个有文档可依的减速带,而提供方拒绝会毒化整个会话、表现为一次无从解释的反复失败,且只能靠换模型或重开会话脱身。把补救办法写进配置模型页即可补上可发现性的缺口;而毒化的会话没有任何东西能补。
  • 让路由值盖住 catalogcompat 的顺序:条目 → 路由 → catalog—— 可以让把 catalog 路由改指到自家网关的部署,一句话声明「这里没有视觉能力」。被否决是因为同一句话也会在有人照着容量字段类比写下它的路由上,悄悄禁用每一个 catalog 视觉模型;而那个正当场景由该模型自己的 input 承担。覆盖值还必须在路由级改名叫 input,因为在两个货真价实的回退值旁边把它叫作 default* 是名不副实。
  • 完全不要路由字段,只要条目字段 —— 最贴近上游(上游没有路由级概念)。被否决的理由是产品自身流程会产生的批量场景:「获取可用模型」一次采纳三十个不带模态的 id全是视觉模型的网关就得逐个手写 input
  • 只要路由级 defaultInput,不要条目字段 —— 无法在一条路由上混合模态,也无法修正单个 catalog 模型,唯一的变通办法只剩「把该提供方拆成两个路由键」,代价是多一个永久的 provider id 和每个模型选择器里的一项重复。
  • 探测端点的模态 —— 没有任何 OpenAI 兼容的列表端点会报告它们。
  • 从模型 id 推断*-vision*-vl)—— 命名约定不是能力,网关可以随意改名。
  • 继续拒绝,只改进诊断 —— 那条消息对状态的描述本来就准确,对补救办法则毫无用处;缺的正是补救办法。

影响

自定义提供方下的视觉模型只需在 settings 文档里写一行 input: [text, image]——或者当该路由列出的模型全都接受图片时,在路由上写一行。这就是修复的全部:三个准入点随即接纳它上面的图片,read_image 也可用。什么都不写的部署保持原有行为完全不变,因此没有任何既有路由改变它报出的内容。

图片准入门禁在各处都保住了自己的意义,因为它读到的每一个模态,如今要么由已安装 catalog 记录,要么由人写下。没有任何环节会替部署宣称一项能力。

声明了端点并不提供的图片能力的模型不会在本地被拦下——该断言不经验证——而由此产生的失败代价高昂。prompt 准入在构造请求之前就把用户消息持久化提交(agent/inbox/spliced因此被拒绝的图片会留在会话日志里该模型会不断重发它而模型选择拒绝切换到任何纯文本模型。恢复途径是选择一个确实提供图片能力的模型、fork 到图片之前,或者开启新会话。让这次失败不具破坏性——发送失败时把尚未消费的图片消息从日志中回滚出去——才是能让乐观默认值重新可考虑的那项改动,本次未做尝试。

测试

packages/llm/llm-pi-ai/tests/catalog.spec.ts 覆盖了这条链的每一级以及空列表的两种读法一条路由上未声明的模型与条目声明的纯文本、视觉模型并存路由默认值为未声明的模型作答而条目仍然压过它catalog 视觉模型在更窄的路由默认值下保住自身模态,条目的 [] 走继承而非清空,以及路由的 [] 被拒绝。报出的元数据经由真实 ctx.llm.listModels 组合断言。

没有任何无密钥 snapshot 通道会跑 pi-ai 路由snapshot 示例驱动的是 dsh-llm-replay,它在自己的配置里直接声明模态,而 pi-ai 路由需要一个真实端点,其端口是静态 cordis.yml 无法写出的。本次变更所供给的那些准入点已经通过该提供方在那里得到覆盖(examples/acp-agent/image.cordis.snapshot.ymlimage-text-route.cordis.snapshot.yml)且不受影响——改变的是某个适配器报告什么,而非门禁如何读取它。