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.
This commit is contained in:
Yichen Jiang
2026-08-12 15:11:46 +08:00
parent e9dfb30cdf
commit 94289d057e
15 changed files with 443 additions and 19 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-12-pi-ai-route-default-input-modalities.md
2026-08-12-pi-ai-route-default-input-modalities.md: 7997c8af6d73f5e034af4f3f5af989ea7bb5f8da
2026-08-12-pi-ai-route-default-input-modalities.zh.md: 0d53e95d67fbcabc9685cce1643d091ecb6f74d8

View File

@@ -0,0 +1,51 @@
# Agent Note: A pi-ai model declares its own input modalities, and undeclared means text
Status: implemented
English | [中文](2026-08-12-pi-ai-route-default-input-modalities.zh.md)
## Problem
Nothing in `settings.yaml` could describe a hand-declared pi-ai model as accepting images, and the adapter assumed text-only for every model the installed pi-ai catalog does not describe. Every model a deployment adds through the web UI's "add a custom provider" card is such a model, so an OpenAI-compatible gateway serving a vision model reported `inputModalities: ['text']` no matter what it actually served.
The harness treats an omitted modality as negative capability, and three admission points act on it before any request is built: model selection refuses to switch into a session that already holds images, prompt admission refuses an image, and `read_image` refuses to read one. Their diagnostics tell the user to select an image-capable model — advice with no reachable referent, because no configuration key could make a hand-declared model image-capable. The route was closed at the metadata, not at the capability: the request converter and every pi-ai wire protocol carry images, and `llm-pi-ai`'s own stream guard is the only thing that would have stopped one.
The assumption was justified in the source as the adapter's real capability rather than a deployment choice. That justification described the DeepSeek chat-completions adapter, whose serializer genuinely rejects image blocks, and had never been true of the pi-ai route.
## Decision
**Modalities resolve entry `input` → installed catalog entry → route `defaultInput`, which itself defaults to `[text]`.** That is the chain `contextWindow` and `maxTokens` already use, field for field. pi-ai types `Model.input` required and per-model, so the entry field mirrors upstream directly: one route can serve a vision model beside a text-only one, and an override can correct a catalog model whose gateway serves other modalities than the catalog records. The route field spares a gateway whose *undescribed* models all take images from repeating itself on every entry.
**The route value is a fallback, not an override — the catalog outranks it.** This is the `default*` ordering rather than `compat`'s, and the two are not interchangeable: `compat` shadows the catalog because a route-level protocol repoint invalidates the catalog's reasoning-dispatch facts wholesale, while a modality is a per-model property the catalog states accurately for the models it ships. Making the route value win would mean `defaultInput: [text]` silently strips images from every catalog vision model on the route — a footgun with no matching benefit, since narrowing one such model is what that model's own `input` is for.
**Undeclared means `[text]`, and that is the absence of a declaration rather than a guess at the endpoint.** Nothing can interrogate a gateway for its modalities — no OpenAI-compatible listing endpoint reports them — so the only honest floor is the modality every supported protocol certainly carries. This is where the modality fallback parts company with the capacity ones: 262,144 tokens is merely plausible and wrong in both directions (a gateway serving 8k overflows, one serving 1M is wasted), while text is safe in one direction. The two wrong answers do not cost the same either. Under-claiming refuses the image before it is attached, naming the model, and the remedy is one documented line. Over-claiming admits an image the provider then rejects mid-turn, *after* prompt admission has committed the message durably, so the session keeps re-sending a request that cannot succeed and model selection refuses a switch to any text-only model. A cheap refusal at the earliest resolvable point beats an expensive one at the latest.
**An entry's empty list means the same as an absent one; the route's is refused.** `[]` describes a model that accepts nothing and could serve no request, so it states no answer and resolution continues past it. That reading is not cosmetic: the config schema materializes `[]` for an absent array, so treating it as "accepts nothing" would silently strip images from every catalog vision model a `models` list happens to name. The route value has nothing below it to answer instead, so its empty list is refused where it is written. The route's `models` list already resolves absent-and-empty the same way for the same reason.
**No configuration surface edits `input`.** It joins `compat`, `reasoningEfforts`, `thinkingBudgets`, and `headers` as a settings-document field, and the model-list editor stays a hand-written form over id, name, and the two capacities. This costs nothing durable because that card was already built to carry fields it does not edit: its row patch spreads the stored row before applying changes, and adoption keeps an existing row over a rediscovered candidate, so a hand-written `input` survives both.
The DeepSeek chat-completions adapter is untouched. Its `['text']` is a fact about its serializer, not a missing declaration, and it keeps refusing before the send.
## Alternatives considered
- **An optimistic `[text, image]` default** — makes the motivating case work with zero configuration, and the web form writes no modality at all, so a conservative default leaves the remedy in a file a web-only user has no reason to open. Rejected on the severity of being wrong: a refused attachment is a speed bump with a documented fix, while a provider rejection poisons the session, presents as an unexplained repeating failure, and is escapable only by switching models or starting over. Documenting the remedy on the model-configuration page closes the discoverability gap; nothing closes the poisoned session.
- **A route value that overrides the catalog** (`compat`'s ordering: entry → route → catalog) — lets a deployment that repoints a catalog route at its own gateway declare "no vision here" once. Rejected because the same sentence then silently disables every catalog vision model on a route where someone wrote it by analogy with the capacity fields, and the legitimate case is served by that model's own `input`. An override would also have to be named `input` at the route, since calling it `default*` beside two genuine fallbacks would misdescribe it.
- **No route field at all, only the entry one** — closest to upstream, which has no route-level concept. Rejected on the bulk case the product's own flow produces: "fetch available models" adopts thirty ids with no modality, and an all-vision gateway would need `input` hand-written on each.
- **A route-level `defaultInput` with no entry field** — cannot mix modalities on one route or correct a single catalog model, leaving "split the provider across two route keys" as the only workaround, at the cost of a second permanent provider id and a duplicate entry in every model selector.
- **Probe the endpoint for its modalities** — no OpenAI-compatible listing endpoint reports them.
- **Infer from the model id** (`*-vision`, `*-vl`) — a naming convention is not a capability, and a gateway renames freely.
- **Keep refusing and improve the diagnostic** — the message was already accurate about the state and useless about the remedy; the missing thing was the remedy.
## Consequences
A vision model on a custom provider costs one line, `input: [text, image]`, written in the settings document — or one line at the route when every model it lists takes images. That is the whole of the fix: the three admission points then admit images on it and `read_image` works. A deployment that writes nothing keeps exactly the behavior it had, so no existing route changes what it reports.
The image-admission gate keeps its meaning everywhere, because every modality it reads is now either recorded by the installed catalog or written by a person. Nothing claims a capability on a deployment's behalf.
A model that declares images its endpoint does not serve is not caught locally — the claim is not verified — and the resulting failure is expensive. Prompt admission commits the user message durably (`agent/inbox/spliced`) before the request is built, so the rejected image stays in the session log: that model keeps re-sending it, and model selection refuses a switch to any text-only model. Recovery is to select a model that does serve images, fork before the image, or start a session. Making that failure non-destructive — rolling an unconsumed image message back out of the log when the send fails — is the change that would make an optimistic default reconsiderable, and is not attempted here.
## Testing
`packages/llm/llm-pi-ai/tests/catalog.spec.ts` covers each rung of the chain and both readings of an empty list: one route mixing an undeclared model with entry-declared text-only and vision models, a route default answering an undeclared model while an entry still outranks it, a catalog vision model keeping its modalities under a narrower route default, an entry's `[]` inheriting rather than emptying, and the route's `[]` refused. The reported metadata is asserted through a real `ctx.llm.listModels` composition.
No keyless snapshot lane exercises a pi-ai route: the snapshot examples drive `dsh-llm-replay`, which declares modalities directly in its configuration, and a pi-ai route needs a live endpoint whose port a static `cordis.yml` cannot name. The admission points this change feeds are already covered there through that provider (`examples/acp-agent/image.cordis.snapshot.yml` and `image-text-route.cordis.snapshot.yml`) and are unaffected — what changed is what one adapter reports, not how a gate reads it.

View File

@@ -0,0 +1,51 @@
# Agent Notepi-ai 模型自行声明输入模态,未声明即为文本
Status: implemented
[English](2026-08-12-pi-ai-route-default-input-modalities.md) | 中文
## 问题
`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]`。** 这与 `contextWindow``maxTokens` 已经在用的链条逐字段一致。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`。** 它和 `compat``reasoningEfforts``thinkingBudgets``headers` 一样是 settings 文档字段,而模型列表编辑器仍是一张只覆盖 id、名称和两个容量的手写表单。这不会带来持久代价因为那张卡片本来就是按“承载自己并不编辑的字段”建造的它的行 patch 会先展开已存储的行再应用改动,而采纳候选时已有行优先于重新发现的候选,因此手写的 `input` 在两条路径上都能存活。
DeepSeek chat-completions 适配器保持不动。它的 `['text']` 是关于其序列化器的事实,而不是一处缺失的声明,它继续在发送前拒绝。
## 备选方案
- **乐观的 `[text, image]` 默认值** —— 让触发本次变更的场景零配置即可工作;而且网页表单不会写入任何模态,因此保守默认值会把补救办法留在一个纯 Web 用户没有理由打开的文件里。被否决的理由是猜错时的严重程度:被拒绝的附件是一个有文档可依的减速带,而提供方拒绝会毒化整个会话、表现为一次无从解释的反复失败,且只能靠换模型或重开会话脱身。把补救办法写进配置模型页即可补上可发现性的缺口;而毒化的会话没有任何东西能补。
- **让路由值盖住 catalog**`compat` 的顺序:条目 → 路由 → 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.yml``image-text-route.cordis.snapshot.yml`)且不受影响——改变的是某个适配器报告什么,而非门禁如何读取它。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: b2a5a0469bad63297d0c3509f8d76f387beb58cb
config-catalog.zh.md: 5bf93b4b3dcbcfc4e55ee4ed5fb1c77d17c86ab2
config-catalog.md: 5442e18d57de9991f53283e6623c2dbe4788b32b
config-catalog.zh.md: 7ba2449a00494963cb014e1be95b60e143efe4b4

View File

@@ -858,6 +858,17 @@ export interface PiAiProviderProfile {
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
/**
* Request modalities for a model this route lists that neither its entry's
* {@link PiAiModelProfile.input} nor the installed catalog declares (default
* `[text]`). A fallback like the capacities above, not an override: a
* catalog model keeps the modalities the catalog records for it, and this
* value never narrows one. A gateway serving vision models the catalog does
* not describe declares `[text, image]` once here instead of on every entry.
* Unlike an entry's list, this one may not be empty — nothing sits below it
* to answer instead.
*/
defaultInput?: PiAiModality[]
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
@@ -893,6 +904,18 @@ export interface PiAiModelProfile {
* default on its own.
*/
maxTokens?: number
/**
* Request modalities this model accepts. Absent — or empty, which describes
* a model that accepts nothing and so states no answer either — keeps the
* installed catalog entry's modalities, then the route's `defaultInput`.
* Declaring images is what makes a hand-declared vision model usable, and
* declaring text alone corrects a catalog model whose gateway does not serve
* what the catalog records. This is a claim about the endpoint, not a check
* of it: nothing interrogates a gateway for what it accepts, so a model
* claiming images its endpoint refuses is refused by the provider instead,
* mid-turn.
*/
input?: PiAiModality[]
/**
* Selectable reasoning efforts. Absent inherits the installed catalog
* entry's capability (a hand-declared model has none and does not reason);
@@ -930,6 +953,9 @@ export interface PiAiCompatProfile {
supportsReasoningEffort?: boolean
}
/** One request modality a pi-ai model may accept. */
export type PiAiModality = Model<Api>['input'][number]
/**
* Selectable reasoning efforts for one model: each key is a level the model
* offers (and selectors show), and its value is the wire spelling dispatch
@@ -953,9 +979,9 @@ type PiThinkingFormat = NonNullable<OpenAICompletionsCompat['thinkingFormat']>
type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
```
Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
Source: [`packages/llm/llm-pi-ai/src/config.ts:142`](../packages/llm/llm-pi-ai/src/config.ts)
Source: [`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts)
## `@deepseek-ai/dsh-llm-replay`

View File

@@ -860,6 +860,17 @@ export interface PiAiProviderProfile {
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
/**
* Request modalities for a model this route lists that neither its entry's
* {@link PiAiModelProfile.input} nor the installed catalog declares (default
* `[text]`). A fallback like the capacities above, not an override: a
* catalog model keeps the modalities the catalog records for it, and this
* value never narrows one. A gateway serving vision models the catalog does
* not describe declares `[text, image]` once here instead of on every entry.
* Unlike an entry's list, this one may not be empty — nothing sits below it
* to answer instead.
*/
defaultInput?: PiAiModality[]
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
@@ -895,6 +906,18 @@ export interface PiAiModelProfile {
* default on its own.
*/
maxTokens?: number
/**
* Request modalities this model accepts. Absent — or empty, which describes
* a model that accepts nothing and so states no answer either — keeps the
* installed catalog entry's modalities, then the route's `defaultInput`.
* Declaring images is what makes a hand-declared vision model usable, and
* declaring text alone corrects a catalog model whose gateway does not serve
* what the catalog records. This is a claim about the endpoint, not a check
* of it: nothing interrogates a gateway for what it accepts, so a model
* claiming images its endpoint refuses is refused by the provider instead,
* mid-turn.
*/
input?: PiAiModality[]
/**
* Selectable reasoning efforts. Absent inherits the installed catalog
* entry's capability (a hand-declared model has none and does not reason);
@@ -932,6 +955,9 @@ export interface PiAiCompatProfile {
supportsReasoningEffort?: boolean
}
/** One request modality a pi-ai model may accept. */
export type PiAiModality = Model<Api>['input'][number]
/**
* Selectable reasoning efforts for one model: each key is a level the model
* offers (and selectors show), and its value is the wire spelling dispatch
@@ -955,9 +981,9 @@ type PiThinkingFormat = NonNullable<OpenAICompletionsCompat['thinkingFormat']>
type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
```
依赖:`CacheRetention``@earendil-works/pi-ai`)· `ModelThinkingLevel``@earendil-works/pi-ai`)· `OpenAICompletionsCompat``@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets``@earendil-works/pi-ai`)· `Transport``@earendil-works/pi-ai`
依赖:`Api``@earendil-works/pi-ai`)· `CacheRetention``@earendil-works/pi-ai`)· `Model``@earendil-works/pi-ai`)· `ModelThinkingLevel``@earendil-works/pi-ai`)· `OpenAICompletionsCompat``@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets``@earendil-works/pi-ai`)· `Transport``@earendil-works/pi-ai`
来源:[`packages/llm/llm-pi-ai/src/config.ts:142`](../packages/llm/llm-pi-ai/src/config.ts)
来源:[`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts)
## `@deepseek-ai/dsh-llm-replay`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/providers.md
providers.md: a3f94f0cc86401c0f9e5b94cfd823bf9f08e6bfc
providers.zh.md: 7d74e0086e62d8a0c2fb39085207125b4b4354e7
providers.md: 099f434ec4602aa402239e83c708d81fcadd7732
providers.zh.md: 367c90b525ad628b3cd86b2d22045c25064e88a1

View File

@@ -28,6 +28,57 @@ The Provider ID is permanent because requests, saved sessions, model defaults, a
Under **Model catalog**, choose **Fetch available models** to query the base URL and credential currently shown in the form. Selecting candidates updates the draft; the provider is not stored until you save. Catalog providers use their installed catalog without a network request.
### Image input
A model you enter by hand is treated as text-only until it says otherwise, because nothing can ask an endpoint which modalities it accepts. Attaching an image to such a model is refused before it is sent, naming the model.
A vision model on a custom provider therefore needs one line. The form has no field for it; add `input` to the model in `$DSH_HOME/settings.yaml`:
```yaml
llm-pi-ai:
providers:
my-gateway:
apiKeyEnv: GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.example/v1
models:
- id: legacy-chat
- id: vision-preview
input: [text, image]
```
`input` accepts `text` and `image`, and applies to that model alone, so one route can serve both kinds. Omitting it — or writing an empty list, which means the same thing — keeps whatever the installed catalog records for that model, and falls back to the route's `defaultInput` for a model the catalog does not describe.
If every model you entered by hand takes images, set the fallback once on the route instead of on each of them:
```yaml
llm-pi-ai:
providers:
vision-gateway:
apiKeyEnv: GATEWAY_API_KEY
api: openai-completions
baseURL: https://vision.example/v1
defaultInput: [text, image]
models:
- id: first-model
- id: second-model
```
`defaultInput` is a fallback, not an override, and defaults to `[text]`: on a catalog provider it answers only for models the catalog does not describe, so it never removes images from a catalog model that has them. Narrow one of those with that model's own `input`. A catalog provider has no `models` list to put it in, so write it under `modelOverrides`, keyed by model id:
```yaml
llm-pi-ai:
providers:
anthropic:
modelOverrides:
claude-sonnet-4-5:
input: [text]
```
Every list must name at least one modality except a model's own, where an empty list means the same as omitting it. An unknown modality is refused wherever it is written.
Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead.
## Select a model
Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log.
@@ -39,6 +90,8 @@ If a saved default names a provider that was deleted, the composer displays **Se
- **`MISSING_CREDENTIAL`** — Store the provider key through the Models page or supply the referenced environment variable.
- **`UNKNOWN_MODEL`** — Select a configured model or add the missing model to the custom provider.
- **Fetching available models returns 401** — Check the key. Model discovery calls the OpenAI-compatible `GET /models` endpoint; enter models manually for endpoints that do not provide it.
- **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; DeepSeek's own chat-completions route is text-only and cannot be configured otherwise.
- **The provider rejects a request carrying an image** — The model declares images its endpoint does not actually serve. Remove `image` from whichever list granted it — the model's `input`, or the route's `defaultInput` — then start a new session: the attached image stays in the session log, so the same request repeats until the session moves off it.
## Advanced configuration

View File

@@ -28,6 +28,57 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
在**模型目录**中选择**获取可用模型**,可查询表单当前显示的基础 URL 和凭据。选择候选项只会更新草稿;保存前不会存储提供方。目录提供方使用已安装目录,不发起网络请求。
### 图片输入
手动输入的模型在自己声明之前一律按纯文本对待,因为没有任何环节能去询问端点接受哪些模态。给这类模型附加图片,会在发送前就被拒绝,并点名该模型。
因此自定义提供方下的视觉模型需要加一行。表单没有对应字段;请在 `$DSH_HOME/settings.yaml` 中给该模型加上 `input`
```yaml
llm-pi-ai:
providers:
my-gateway:
apiKeyEnv: GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.example/v1
models:
- id: legacy-chat
- id: vision-preview
input: [text, image]
```
`input` 接受 `text``image`,且只作用于该模型,因此一条路由可以同时服务两类模型。省略它——或写成空列表,两者同义——则保留已安装目录为该模型记录的模态;目录未描述的模型则回退到该路由的 `defaultInput`
如果你手动录入的模型全都接受图片,可以在路由上设置一次回退值,不必逐个模型写:
```yaml
llm-pi-ai:
providers:
vision-gateway:
apiKeyEnv: GATEWAY_API_KEY
api: openai-completions
baseURL: https://vision.example/v1
defaultInput: [text, image]
models:
- id: first-model
- id: second-model
```
`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在目录提供方上,它只为目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。目录提供方没有可供填写的 `models` 列表,因此写在 `modelOverrides` 下,以模型 id 为键:
```yaml
llm-pi-ai:
providers:
anthropic:
modelOverrides:
claude-sonnet-4-5:
input: [text]
```
除模型自身的列表外,每个列表都至少要写一项模态;模型自身的空列表与省略它同义。未知模态在任何位置写入都会被拒绝。
这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。
## 选择模型
已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。
@@ -39,6 +90,8 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和
- **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。
- **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。
- **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。
- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`DeepSeek 自身的 chat-completions 路由是纯文本的,且无法通过配置改变。
- **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。
## 进阶配置

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
README.md: abf1924a1d1297a8fb7300710223886ccf8bc931
README.zh.md: f4c5ddd6dbe05ae709145cfac341f17a716bac82
README.md: b8276e3b9a77cfa23ad32733a8b3754d3bfd8a9a
README.zh.md: 94387c879f04be9bfe09c1518b50540e4d79ba84

View File

@@ -91,6 +91,10 @@ How a thinking level travels — `reasoning_effort` alone, DeepSeek's `thinking:
A model neither the entry nor the installed catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768), so a listing that discloses nothing but ids still yields a serviceable route. Both fallbacks are guesses by construction, which is why they are route fields a deployment whose gateway serves smaller models corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap.
Request modalities resolve entry `input` → installed catalog entry → route `defaultInput` (default `[text]`), the same order and the same fallback role the capacities above use. So a catalog model keeps the modalities the catalog records for it, and a narrower route default never strips them; a gateway whose *undescribed* models all take images declares `[text, image]` once at the route instead of on every entry. An entry's empty list means the same as an absent one — it describes a model accepting nothing, so it states no answer and resolution continues past it, which is what keeps a catalog model's own modalities when a `models` entry names it without declaring any. The route's may not be empty, since nothing sits below it to answer instead.
`[text]` is the absence of a declaration rather than a guess at the endpoint, which is why the fallback here is conservative where the capacity fallbacks are merely plausible. Nothing interrogates a gateway for what it accepts, and the two wrong answers do not cost the same: the harness refuses an image before it is attached when a model's modalities omit one, so under-claiming costs a refusal naming the model, while over-claiming admits an image the provider then rejects mid-turn — after the message is durable, which leaves the session repeating a request that cannot succeed.
Resolution still fails loud, naming the offending route and model, when a route cannot be served at all: a route the catalog does not ship needs `api`, `baseURL`, and a non-empty `models` list of uniquely-identified models. That resolution runs inside the section schema, so an unserviceable profile is refused **where it is written**`settings.mutate` answers `settings-rejected` naming the route and model — rather than being stored and then quietly disabling every route in the namespace. The settings seam keeps a namespace's last good value for an already-stored section that fails, so this cannot strand a deployment. `api` accepts the protocols in `supportedProtocols()` and is only needed when the catalog cannot supply one: a model absent from the catalog inherits the protocol its shipped siblings agree on, so adding a model to a single-protocol catalog route restates nothing.
`baseURL` sets the endpoint of every model on the route, so private proxies such as `https://proxy.example.com:8443` remain supported; a catalog route that omits it keeps each catalog model's own endpoint. Naming `api` on a catalog route repoints the whole route at that protocol, which is how a deployment moves a provider between, say, Responses and Chat Completions.
@@ -109,7 +113,7 @@ A model that carries reasoning metadata — from the installed catalog or from i
A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`.
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.

View File

@@ -91,6 +91,10 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
条目与已安装 catalog 都没有给出尺寸的模型,会采用该路由的 `defaultContextWindow`262,144`defaultMaxTokens`32,768因此一份只公布 id 的列表同样能产出可服务的路由。两个回退值本质上都是猜测,这正是它们作为路由字段、供网关服务更小模型的部署一次性更正的原因,而不是埋在适配器里的常量;回退值只用于给模型定尺寸,绝不会变成每请求上限。
请求模态的解析顺序是:条目的 `input` → 已安装 catalog 条目 → 路由的 `defaultInput`(默认 `[text]`),与上面两个容量字段的顺序和「回退值」定位完全一致。因此 catalog 模型保留 catalog 为它记录的模态,更窄的路由默认值也绝不会把它剥掉;而**未被 catalog 描述的**模型全都接受图片的网关,只需在路由上写一次 `[text, image]`,不必逐条目写。条目的空列表与缺省同义——它描述的是一个什么都不接受的模型,因此不作答,解析继续往下走——这正是当 `models` 条目点到某个 catalog 模型却不声明模态时,该模型仍保留 catalog 自有模态的原因。路由的那个则不得为空,因为它下面已经没有可以代为作答的层级。
`[text]` 是「尚未声明」而不是对端点的猜测——这也是为什么这里的回退值取保守值而两个容量回退值只是取一个说得过去的值。这里没有任何环节会去询问网关实际接受什么而两种猜错的代价并不对等模态中不含图片时Harness 会在图片被附加之前就拒绝,因此少声明的代价是一次点名该模型的拒绝;而多声明会接纳一张图片、再由提供方在轮次中途拒绝——此时消息已经持久化,会话便会不断重复一个不可能成功的请求。
路由完全无法服务时解析仍会失败得响亮并点名出问题的路由与模型catalog 未提供的路由需要 `api``baseURL`,以及一个由唯一标识的模型组成的非空 `models` 列表。该解析在分节 schema 内部运行,因此无法服务的 profile 会在**写入之处**被拒绝——`settings.mutate``settings-rejected` 点名路由与模型——而不是先存下来、再悄悄让该 namespace 下每条路由失效。对于已经存下的、在此失败的分节settings seam 会保留该 namespace 上一份可用值,因此这不会把部署卡死。`api` 接受 `supportedProtocols()` 中的协议,且仅在 catalog 无法提供协议时才需要catalog 中不存在的模型会继承其同门模型一致同意的协议,因此向单协议 catalog 路由添加模型无需重述任何内容。
`baseURL` 设定该路由下每个模型的端点,因此仍支持 `https://proxy.example.com:8443` 等私有 proxy省略它的 catalog 路由会保留每个 catalog 模型自己的端点。在 catalog 路由上点名 `api` 会把整条路由改指到该协议,这正是部署把某个提供方在 Responses 与 Chat Completions 之间迁移的方式。
@@ -109,7 +113,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`
受支持的 profile 字段是 `apiKeyEnv``displayName``api``baseURL``models``modelOverrides``compat``defaultContextWindow``defaultMaxTokens``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
受支持的 profile 字段是 `apiKeyEnv``displayName``api``baseURL``models``modelOverrides``compat``defaultContextWindow``defaultMaxTokens``defaultInput``headers``reasoning``thinkingBudgets``cacheRetention``transport``timeoutMs``websocketConnectTimeoutMs``streamIdleTimeoutMs``retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries``maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent智能体级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`

View File

@@ -31,12 +31,34 @@ import type {
*/
const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
/** One request modality a pi-ai model may accept. */
export type PiAiModality = Model<Api>['input'][number]
/**
* Input modalities for a model the installed catalog does not describe. The
* request converter keeps only text blocks, so text is the adapter's actual
* capability rather than a deployment choice.
* Every pi-ai request modality. The `Record` key type is a drift gate: a pi-ai
* upgrade that adds or removes a modality fails compilation here naming the
* drifted key, instead of silently narrowing what a profile may declare.
*/
const TEXT_ONLY: Model<Api>['input'] = ['text']
const MODALITY_GATE: Record<PiAiModality, true> = {
text: true,
image: true,
}
/** Every request modality a profile may declare. */
export const MODALITIES = Object.keys(MODALITY_GATE) as readonly PiAiModality[]
/**
* One entry's modality list, or `undefined` when it states no answer. Absent
* and empty mean the same thing — `[]` describes a model that accepts nothing
* and could serve no request — which is what makes an entry naming a catalog
* model without declaring modalities keep the catalog's, since the config
* schema materializes `[]` for an absent array.
* @param configured - the list a `models` or `modelOverrides` entry supplied.
* @returns the declared modalities, or `undefined` to ask the next level.
*/
function declaredInput(configured: readonly PiAiModality[] | undefined): Model<Api>['input'] | undefined {
return configured === undefined || configured.length === 0 ? undefined : [...configured]
}
/**
* Every pi-ai thinking level, in pi-ai's canonical escalation order. The
@@ -171,6 +193,18 @@ export interface PiAiModelProfile {
* default on its own.
*/
maxTokens?: number
/**
* Request modalities this model accepts. Absent — or empty, which describes
* a model that accepts nothing and so states no answer either — keeps the
* installed catalog entry's modalities, then the route's `defaultInput`.
* Declaring images is what makes a hand-declared vision model usable, and
* declaring text alone corrects a catalog model whose gateway does not serve
* what the catalog records. This is a claim about the endpoint, not a check
* of it: nothing interrogates a gateway for what it accepts, so a model
* claiming images its endpoint refuses is refused by the provider instead,
* mid-turn.
*/
input?: PiAiModality[]
/**
* Selectable reasoning efforts. Absent inherits the installed catalog
* entry's capability (a hand-declared model has none and does not reason);
@@ -210,6 +244,8 @@ export interface RouteCatalogRequest {
defaultContextWindow: number
/** Output capability for a model neither the entry nor the catalog sizes. */
defaultMaxTokens: number
/** Modalities for a model neither the entry nor the catalog declares. */
defaultInput: Model<Api>['input']
}
/** Report a route the deployment cannot serve, naming the settings key at fault. */
@@ -474,7 +510,7 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
api,
provider,
baseUrl,
input: base?.input ?? TEXT_ONLY,
input: declaredInput(entry.input) ?? base?.input ?? request.defaultInput,
cost: base?.cost ?? NO_COST,
contextWindow,
maxTokens,

View File

@@ -21,8 +21,14 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
import { resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts'
import type { PiAiCompatProfile, PiAiModelOverride, PiAiModelProfile, PiAiReasoningEfforts } from './catalog.ts'
import { MODALITIES, resolveRouteModels, SUPPORTED_THINKING_FORMATS, THINKING_LEVELS } from './catalog.ts'
import type {
PiAiCompatProfile,
PiAiModality,
PiAiModelOverride,
PiAiModelProfile,
PiAiReasoningEfforts,
} from './catalog.ts'
import { buildProvider, supportedProtocols } from './provider.ts'
/** Default maximum idle interval while an adapter stream read is outstanding. */
@@ -34,8 +40,21 @@ export const DEFAULT_CONTEXT_WINDOW = 262_144
/** Output capability assumed for a model neither configuration nor the catalog sizes. */
export const DEFAULT_MAX_TOKENS = 32_768
/**
* Modalities assumed for a model neither configuration nor the catalog
* declares. Text is the floor every supported protocol certainly carries, so
* this is the absence of a declaration rather than a guess at the endpoint:
* nothing can interrogate a gateway for its modalities, and the two wrong
* answers do not cost the same. Under-claiming refuses the image before it is
* attached, naming the model. Over-claiming admits one the provider then
* rejects mid-turn, after the message is durable, leaving the session
* repeating a request that cannot succeed.
*/
export const DEFAULT_INPUT: readonly PiAiModality[] = ['text']
export type {
PiAiCompatProfile,
PiAiModality,
PiAiModelOverride,
PiAiModelProfile,
PiAiReasoningEfforts,
@@ -90,6 +109,17 @@ export interface PiAiProviderProfile {
* never becomes a per-request cap on its own.
*/
defaultMaxTokens?: number
/**
* Request modalities for a model this route lists that neither its entry's
* {@link PiAiModelProfile.input} nor the installed catalog declares (default
* `[text]`). A fallback like the capacities above, not an override: a
* catalog model keeps the modalities the catalog records for it, and this
* value never narrows one. A gateway serving vision models the catalog does
* not describe declares `[text, image]` once here instead of on every entry.
* Unlike an entry's list, this one may not be empty — nothing sits below it
* to answer instead.
*/
defaultInput?: PiAiModality[]
/** Provider request headers; Harness attribution wins reserved names. */
headers?: Record<string, string>
/** Provider-neutral pi-ai reasoning level. */
@@ -180,6 +210,10 @@ const modelFields = {
name: z.string(),
contextWindow: z.number().step(1).min(1),
maxTokens: z.number().step(1).min(1),
// No explicit default, unlike the route's `defaultInput`: schemastery
// materializes `[]` for an absent array, and resolution reads that as "no
// answer here" so the catalog entry below still applies.
input: z.array(z.union(MODALITIES)),
// The union, not a bare dict: schemastery materializes an absent dict as
// `{}`, and absent must stay distinguishable — it means "inherit the
// installed catalog's capability", while `false` disables reasoning.
@@ -205,6 +239,7 @@ const profile = z.object({
compat: compatProfile,
defaultContextWindow: z.number().step(1).min(1).default(DEFAULT_CONTEXT_WINDOW),
defaultMaxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
defaultInput: z.array(z.union(MODALITIES)).default([...DEFAULT_INPUT]),
headers: z.dict(z.string()),
reasoning: z.union(THINKING_LEVELS),
thinkingBudgets,
@@ -288,6 +323,15 @@ export function resolveProfiles(
`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
// Detached from the configuration object because pi-ai types `Model.input`
// mutable and every model falling through to it is handed this array. The
// schema's explicit default covers an absent key, so an empty list here is
// always one someone typed — and unlike an entry's, nothing below it can
// answer instead — so it is refused rather than read as "no answer".
const defaultInput = [...source.defaultInput ?? DEFAULT_INPUT]
if (defaultInput.length === 0) {
throw new Error(`llm-pi-ai: provider "${provider}" defaultInput must name at least one modality`)
}
// The route key, not the installed provider's own name: the directory has
// always shown route keys, and a catalog route must not silently rename
// itself on every configuration surface just because it gained a profile.
@@ -299,6 +343,7 @@ export function resolveProfiles(
...source.models === undefined ? {} : { models: source.models },
...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides },
...source.compat === undefined ? {} : { compat: source.compat },
defaultInput,
defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
})

View File

@@ -186,6 +186,75 @@ describe('hand-declared providers', () => {
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512)
})
it('takes a models declared modalities, then the catalogs, then the routes', () => {
const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image'))
if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model')
const resolved = resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
// One route, two modality sets: the entry field is what says so.
models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }, { id: 'deaf', input: ['text'] }],
},
'seeing-gateway': {
api: 'openai-completions',
baseURL: 'https://seeing.test',
// A gateway whose undescribed models all take images says so once
// rather than on every entry; an entry still outranks it.
defaultInput: ['text', 'image'],
models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }],
},
// The route value is a fallback, never an override: a catalog model
// keeps what the catalog records even under a narrower route default,
// exactly as it keeps its own contextWindow.
'anthropic': { defaultInput: ['text'] },
})
const inputOf = (route: string, id: string): readonly string[] | undefined =>
resolved.get(route)?.piProvider.getModels().find(model => model.id === id)?.input
// Nothing can interrogate the endpoint, so an undeclared model on a route
// that names no default claims only the modality every supported protocol
// carries. Claiming images instead would admit one the provider then
// rejects mid-turn, after the durable message is already in the log.
expect(inputOf('acme-gateway', 'bare')).toEqual(['text'])
expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image'])
expect(inputOf('acme-gateway', 'deaf')).toEqual(['text'])
expect(inputOf('seeing-gateway', 'bare')).toEqual(['text', 'image'])
expect(inputOf('seeing-gateway', 'deaf')).toEqual(['text'])
expect(inputOf('anthropic', vision.id)).toEqual(vision.input)
})
it('reads an entrys empty modality list as no answer, and the routes as unserviceable', () => {
// Absent and empty are the same request on an entry, exactly as they are
// for the route's `models` list — which matters because the config schema
// materializes `[]` for an absent array, so an entry naming a catalog
// model without declaring modalities must keep the catalog's rather than
// describe a model that accepts nothing.
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
const resolved = resolveProfiles({
'deepseek': { baseURL: 'https://catalog.test', models: [{ id: catalogModel.id, input: [] }] },
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'bare', input: [] }],
},
})
expect(resolved.get('acme-gateway')?.piProvider.getModels()[0]?.input).toEqual(['text'])
expect(resolved.get('deepseek')?.piProvider.getModels()[0]?.input).toEqual(catalogModel.input)
// Nothing sits below the route value, so its empty list states no answer
// anything could take, and is refused where it is written.
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
defaultInput: [],
models: [{ id: 'bare' }],
},
})).toThrow(/defaultInput must name at least one modality/)
})
it('rejects a model the route cannot identify', () => {
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })