fix(llm): address model discovery review
This commit is contained in:
1 parent
bfdebf1d22
commit
ccecf4db04
17 files changed
+173
-25
No files matched your search
+2
-2
@@ -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 .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
|
||||
2026-08-04-draft-provider-endpoint-interrogation.md: 9f304ed1fb37bf66d452632b54fa83c0fef44104
|
||||
2026-08-04-draft-provider-endpoint-interrogation.zh.md: b689f76739cc7ebf1e36783de5af4b7130a1bf33
|
||||
2026-08-04-draft-provider-endpoint-interrogation.md: a6d8d32f12711744cb1380d2ab2767dcd3dd63f6
|
||||
2026-08-04-draft-provider-endpoint-interrogation.zh.md: e162ddb30706e871a2a42b5bbe7e09a451296289
|
||||
+2
-4
@@ -21,7 +21,7 @@ Interrogation is keyed by **settings namespace**, not by provider route:
|
||||
- `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
|
||||
- `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
|
||||
|
||||
`dsh-llm-pi-ai` implements `GET {baseURL}/models` with bearer authentication for `openai-completions` and `openai-responses`. `anthropic-messages` uses the native `GET /v1/models` route with `x-api-key` and `anthropic-version: 2023-06-01`; a `baseURL` ending in `/v1` keeps that path instead of duplicating it. The parser reads the standard `data` array shared by both APIs and the enriched `models` object exposed by some compatible gateways; a present `data` array takes precedence. Array entries use their `id`, while object entries use the property key as the request id because a nested `id` may name a shared canonical model; the nested value is only an empty-key fallback. `name`, `display_name`, or `displayName` supplies the display name, with the request id as fallback. `contextWindow`, `context_window`, `context_length`, Anthropic's `max_input_tokens`, or `limit.context` supplies the context window, while `maxOutputTokens`, `max_output_tokens`, `maxTokens`, `max_tokens`, or `limit.output` supplies the output-token cap. Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage pattern for its own caller-supplied URLs.
|
||||
`dsh-llm-pi-ai` applies the protocol-specific listing routes, authentication, URL normalization, response formats, and metadata rules recorded by [protocol-specific model listing discovery](2026-09-02-protocol-specific-model-listing-discovery.md). Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. A protocol without a documented listing contract answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so deployment path segments remain intact. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage pattern for its own caller-supplied URLs.
|
||||
|
||||
### Why not pi-ai's own refresh machinery
|
||||
|
||||
@@ -35,9 +35,7 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a `
|
||||
|
||||
**Have the host read the entire stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. The draft remains authoritative for the endpoint and protocol. The narrow Host-side exceptions are the stored credential, which is write-only, and profile headers, which remain deployment configuration rather than Models-page fields.
|
||||
|
||||
**Parse every pi-ai protocol as an OpenAI listing.** Anthropic shares the `data` envelope but has a documented native route and authentication headers, so it is supported explicitly rather than by guessing. Google's field set and Azure's request contract differ, while Codex uses OAuth; treating any of them as OpenAI would make a wrong guess indistinguishable from a provider with no models. An unsupported protocol sends the user to hand-entry, which remains the documented fallback.
|
||||
|
||||
**Send an OpenAI SDK `User-Agent` only for model discovery.** That makes one gateway return the standard array, but it misattributes Harness traffic and makes discovery depend on an undocumented client-name branch. Reading both known OpenAI-compatible reply formats preserves Harness attribution and keeps the compatibility rule in response parsing.
|
||||
**Interrogate every pi-ai protocol.** Coverage based on convenient response similarities would be arbitrary and would make a wrong guess indistinguishable from a provider with no models. Anthropic is included only through its documented native listing contract, as the [protocol-specific extension](2026-09-02-protocol-specific-model-listing-discovery.md) records; Google's field set and Azure's request contract differ, while Codex uses OAuth. An unsupported protocol sends the user to hand-entry, which remains the documented fallback.
|
||||
|
||||
**Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed.
|
||||
|
||||
|
||||
+2
-4
@@ -21,7 +21,7 @@ Status: implemented
|
||||
- `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。
|
||||
- `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。
|
||||
|
||||
`dsh-llm-pi-ai` 对 `openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`。`anthropic-messages` 使用带 `x-api-key` 与 `anthropic-version: 2023-06-01` 的原生 `GET /v1/models` 路由;若 `baseURL` 已经以 `/v1` 结尾,就会保留而非重复追加该路径。解析器既读取两种 API 共用的标准 `data` 数组,也读取部分兼容网关提供的富信息 `models` 对象;若存在 `data` 数组则以它为准。数组条目使用自身的 `id`,对象条目则使用属性键作为请求 id,因为嵌套 `id` 可能点名共享的规范模型;只有属性键为空时才回退到嵌套值。显示名称取自 `name`、`display_name` 或 `displayName`,缺失时回退到请求 id。上下文窗口取自 `contextWindow`、`context_window`、`context_length`、Anthropic 的 `max_input_tokens` 或 `limit.context`,最大输出 token 数则取自 `maxOutputTokens`、`max_output_tokens`、`maxTokens`、`max_tokens` 或 `limit.output`。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。Azure 虽有 OpenAI 血统却不在支持范围内——它用 `api-key` header 鉴权并要求 `api-version` 查询参数——Codex 则使用 OAuth;询问二者都会把鉴权失败误报成提供方没有模型。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式模式一致。
|
||||
`dsh-llm-pi-ai` 会应用[协议特定模型列表发现](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录的列表路由、认证、URL 归一化、响应格式与元数据规则。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。没有文档化列表约定的协议会以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此部署路径段会保持不变。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式模式一致。
|
||||
|
||||
### 为什么不用 pi-ai 自己的 refresh 机制
|
||||
|
||||
@@ -35,9 +35,7 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与
|
||||
|
||||
**让 Host 读取整个已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。草稿仍是端点和协议的权威来源。Host 侧的狭窄例外是只写的已存凭据,以及仍属部署配置、而非 Models 页面字段的 profile headers。
|
||||
|
||||
**把 pi-ai 每种协议都当作 OpenAI 列表解析。** Anthropic 虽共用 `data` 信封,却有文档明确规定的原生路由与鉴权 headers,因此这里显式支持它而非靠猜测。Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth;把任一个当作 OpenAI 都会让猜错的响应与「提供方没有模型」无法区分。不支持的协议会把用户送去手工填写,这仍是既定回退路径。
|
||||
|
||||
**只为模型发现发送 OpenAI SDK 的 `User-Agent`。** 这能让某个网关返回标准数组,却会错误标注 Harness 流量,并让发现依赖未文档化的客户端名称分支。同时读取两种已知的 OpenAI 兼容响应格式,可以保留 Harness attribution,并把兼容规则留在响应解析中。
|
||||
**询问每一种 pi-ai 协议。** 根据便利的响应相似性选择覆盖范围会显得武断,也会让猜错的响应与「提供方没有模型」无法区分。Anthropic 仅通过其文档化原生列表约定纳入支持,具体由[协议特定扩展](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录;Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth。不支持的协议会把用户送去手工填写,这仍是既定回退路径。
|
||||
|
||||
**用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。
|
||||
|
||||
|
||||
+6
@@ -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-09-02-protocol-specific-model-listing-discovery.md
|
||||
2026-09-02-protocol-specific-model-listing-discovery.md: a4fb9d3c0fba685727f78d97cf5948da1c184c5e
|
||||
2026-09-02-protocol-specific-model-listing-discovery.zh.md: 66e4dd76432a666ea8c30c931f1db3444a7084d4
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: Reading protocol-specific model listings
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-02-protocol-specific-model-listing-discovery.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [draft provider interrogation](2026-08-04-draft-provider-endpoint-interrogation.md) originally read the OpenAI-compatible `data` array only. Some compatible gateways instead publish an enriched `models` object, while Anthropic publishes a native model-listing route with different authentication and URL rules. Treating either case as unsupported forced a user to copy model ids and capacities by hand even though the endpoint disclosed them.
|
||||
|
||||
One gateway could be made to return an OpenAI-style array by sending an OpenAI SDK `User-Agent`. That behavior was undocumented, changed request attribution, and made the reply depend on a client identity rather than on a supported response parser.
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-llm-pi-ai` reads model listings according to the selected protocol. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer authentication. `anthropic-messages` uses `GET /v1/models?limit=1000` with `x-api-key` and `anthropic-version: 2023-06-01`. The Anthropic page size is the documented maximum; discovery does not follow `has_more`, so an endpoint advertising more than 1,000 models exposes only its first page.
|
||||
|
||||
Anthropic SDK resource methods append `/v1` themselves. Discovery and inference therefore treat a configured Anthropic `baseURL` ending in `/v1` as the same API root as the address without that suffix. Deployment path prefixes remain intact: `https://gateway.example/tenant/v1` lists at `/tenant/v1/models` and sends messages to `/tenant/v1/messages`.
|
||||
|
||||
The parser accepts a `data` array or an enriched `models` object, with a present array taking precedence. Array entries use their `id`; object entries use the property key because a nested `id` may name a canonical model instead of the route alias accepted on requests. Only object-valued map entries are considered models, so primitive directory metadata cannot become a candidate accidentally. A nested `id` is the fallback for an empty property key.
|
||||
|
||||
The parser normalizes the supported name and capacity spellings into `LlmDiscoveredModel`. A missing display name becomes the request id so adoption fills a complete editable row. The request keeps the Harness attribution headers; response parsing, not client impersonation, provides gateway compatibility.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Follow every Anthropic page.** Cursor traversal would return listings larger than 1,000 entries, but it adds multi-request failure, cancellation, cursor-progress, and aggregate-size behavior to a configuration action. The implementation requests Anthropic's maximum page and documents the remaining truncation.
|
||||
|
||||
**Send an OpenAI SDK `User-Agent` for discovery.** This made one gateway return `data`, but it misattributed Harness traffic and relied on an undocumented client-name branch. Reading both known reply formats keeps attribution accurate.
|
||||
|
||||
**Adopt every property of a `models` object.** A primitive-valued property does not prove that its key is a model id and may be directory metadata such as a count or status. Restricting entries to records avoids inventing model candidates.
|
||||
|
||||
## Consequences
|
||||
|
||||
The Models page can interrogate OpenAI-compatible gateways and Anthropic Messages endpoints without changing request identity. Discovered candidates carry route ids, names, context windows, and output-token caps when the endpoint provides them, and name-only listings still receive an editable label through the id fallback. Anthropic addresses work in either root or `/v1` form for both discovery and inference.
|
||||
|
||||
The supported formats remain an explicit compatibility set rather than arbitrary JSON inference. Anthropic accounts with more than 1,000 visible models require hand-entry for entries outside the first page, and primitive-valued `models` properties are ignored.
|
||||
|
||||
## Testing
|
||||
|
||||
Local HTTP-server tests pin both accepted response formats, field normalization, name fallback, ignored malformed entries, Anthropic headers and the maximum-page query. Provider tests drive Anthropic requests through pi-ai and prove that root, `/v1`, and prefixed `/v1` addresses reach exactly one versioned Messages path.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
# Agent Note: 读取协议特定的模型列表
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-02-protocol-specific-model-listing-discovery.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[提供方草稿询问决策](2026-08-04-draft-provider-endpoint-interrogation.zh.md)最初只读取 OpenAI 兼容的 `data` 数组。一些兼容网关改为公布富信息 `models` 对象,而 Anthropic 公布了具有不同认证与 URL 规则的原生模型列表路由。把任一情况视为不受支持,都会迫使用户手工复制模型 id 和容量,即使端点已经公布这些信息。
|
||||
|
||||
向一个网关发送 OpenAI SDK `User-Agent` 可以使其返回 OpenAI 风格数组。该行为没有文档,改变了请求归属,并使回答取决于客户端身份而非受支持的响应解析器。
|
||||
|
||||
## 决策
|
||||
|
||||
`dsh-llm-pi-ai` 按所选协议格式读取模型列表。`openai-completions` 与 `openai-responses` 以 bearer 认证使用 `GET {baseURL}/models`。`anthropic-messages` 以 `x-api-key` 和 `anthropic-version: 2023-06-01` 使用 `GET /v1/models?limit=1000`。Anthropic 页大小采用文档规定的最大值;模型发现不会继续跟随 `has_more`,因此公布超过 1,000 个模型的端点只会暴露第一页。
|
||||
|
||||
Anthropic SDK 资源方法会自行追加 `/v1`。因此,模型发现与推理会把末尾为 `/v1` 的 Anthropic `baseURL` 视为与不带该后缀的地址相同的 API 根地址。部署路径前缀会保留:`https://gateway.example/tenant/v1` 在 `/tenant/v1/models` 列表,并向 `/tenant/v1/messages` 发送消息。
|
||||
|
||||
解析器接受 `data` 数组或富信息 `models` 对象,并在数组存在时优先使用它。数组条目使用自身的 `id`;对象条目使用属性键,因为嵌套 `id` 可能指向规范模型,而不是请求所接受的路由别名。只有值为对象的映射条目才视为模型,因此原始类型的目录元数据不会意外成为候选。空属性键才会回退到嵌套 `id`。
|
||||
|
||||
解析器会把受支持的名称与容量拼写归一化为 `LlmDiscoveredModel`。缺失的显示名会变成请求 id,使采纳操作填入完整的可编辑行。请求保留 Harness 归属标头;网关兼容性由响应解析提供,而非冒充客户端身份。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**跟随 Anthropic 的所有页面。** 游标遍历可以返回超过 1,000 个条目的列表,但会给配置操作增加多请求失败、取消、游标推进与总大小处理。实现请求 Anthropic 的最大页面,并记录剩余截断限制。
|
||||
|
||||
**为模型发现发送 OpenAI SDK `User-Agent`。** 这会让一个网关返回 `data`,但会错误标记 Harness 流量,并依赖未记录的客户端名称分支。读取两种已知响应格式可以保持归属准确。
|
||||
|
||||
**采纳 `models` 对象的每个属性。** 原始类型属性不能证明其键是模型 id,也可能是数量或状态等目录元数据。把条目限制为记录可避免虚构模型候选。
|
||||
|
||||
## 后果
|
||||
|
||||
Models 页面无需改变请求身份,即可询问 OpenAI 兼容网关与 Anthropic Messages 端点。发现的候选会在端点提供时携带路由 id、名称、上下文窗口与最大输出 token 数,只有 id 的列表也会通过 id 回退获得可编辑标签。Anthropic 地址以根地址或 `/v1` 形式配置时,模型发现与推理都能工作。
|
||||
|
||||
受支持格式仍是显式兼容集合,而不是任意 JSON 推断。可见模型超过 1,000 个的 Anthropic 账户需要手工录入第一页之外的条目,原始类型的 `models` 属性会被忽略。
|
||||
|
||||
## 测试
|
||||
|
||||
本地 HTTP 服务器测试钉住两种受支持响应格式、字段归一化、名称回退、忽略畸形条目、Anthropic 标头与最大页查询。提供方测试通过 pi-ai 驱动 Anthropic 请求,并证明根地址、`/v1` 地址和带前缀的 `/v1` 地址只到达一个带版本的 Messages 路径。
|
||||
@@ -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/client/ui-settings-models/README.md
|
||||
README.md: 6710646098402ec32752277c4ef244cf193fa09c
|
||||
README.zh.md: 456cbc1ed7b1a85fe3ae85967e22c8b4c549ec50
|
||||
README.md: cac2785886346a0c1e81009404fdb0da238bbe40
|
||||
README.zh.md: 7df96a9a1b9474b5e88d8f9814f353f92eb1a31d
|
||||
@@ -105,7 +105,7 @@ These limits define the editor's field coverage and the page's reach; they are c
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved.
|
||||
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
|
||||
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
|
||||
- **Interrogation covers OpenAI-compatible endpoints** — within those protocols the adapter accepts a standard `data` array or an enriched `models` map; a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
|
||||
- **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand.
|
||||
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -105,7 +105,7 @@ kind: "package-reference"
|
||||
- **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:在这些协议下,适配器接受标准 `data` 数组或富信息 `models` 对象;讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -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: d1d70a99e80d14150e6858105adf624ee54170bc
|
||||
README.zh.md: d324f29ab2558807588a61b217358192ad0719e9
|
||||
README.md: a49bc393495447e8fa4323c0530de1db860f5570
|
||||
README.zh.md: 0d43672df325a8f6c0526db77119df00a8ba51d6
|
||||
@@ -106,7 +106,7 @@ Profiles are re-read once per operation through the optional settings seam: the
|
||||
|
||||
### Discover models from endpoints
|
||||
|
||||
The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models` semantics with `x-api-key` and `anthropic-version`; a base URL already ending in `/v1` is not extended twice. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves.
|
||||
The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; a base URL already ending in `/v1` is not extended twice for discovery or inference. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves.
|
||||
|
||||
### Failures and recovery
|
||||
|
||||
@@ -212,6 +212,7 @@ These limits define where the adapter stops and future work begins. They are cur
|
||||
- **The layered merge has no delete for dict keys** — a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares can be overridden but not removed by the user layer.
|
||||
- **`headers` can carry a credential the redactor never sees** — profile resolution rejects names and values Fetch cannot represent, but the dict remains plain strings; store credentials as `apiKeyEnv` references.
|
||||
- **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says; nothing here queries a provider for the models it serves.
|
||||
- **Anthropic discovery reads at most 1,000 models** — the request uses the API's maximum page size but does not traverse `has_more`; entries beyond the first page must be added by hand.
|
||||
- **One wire protocol per route** — a mixed-protocol catalog route cannot host a model of the other protocol; splitting the provider across two route keys is the workaround.
|
||||
- **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request.
|
||||
- **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`.
|
||||
|
||||
@@ -106,7 +106,7 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的
|
||||
|
||||
### 从端点发现模型
|
||||
|
||||
插件会回答"该提供方可以提供哪些模型?",供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models` 语义;已经以 `/v1` 结尾的 base URL 不会再次追加该路径。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。
|
||||
插件会回答"该提供方可以提供哪些模型?",供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;已经以 `/v1` 结尾的 base URL 在模型发现或推理时都不会再次追加该路径。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。
|
||||
|
||||
### 失败与恢复
|
||||
|
||||
@@ -212,6 +212,7 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish
|
||||
- **分层合并对字典键没有删除**——base 声明的 `reasoningEfforts` 等级、`modelOverrides` 条目或 `compat` 字段可以被用户层覆盖,但不能被移除。
|
||||
- **`headers` 可以携带 redactor 永远看不到的凭据**——profile 解析会拒绝 Fetch 无法表示的名称与值,但该字典仍是纯字符串;以 `apiKeyEnv` 引用存储凭据。
|
||||
- **路由目录不会自行刷新**——目录就是 `settings.yaml` 的内容;这里没有任何机制向提供方查询它提供的模型。
|
||||
- **Anthropic 模型发现最多读取 1,000 个模型**——请求使用 API 的最大页大小,但不会遍历 `has_more`;第一页之外的条目需要手工添加。
|
||||
- **每条路由一种协议格式**——混合协议目录路由无法承载另一协议格式的模型;把提供方拆到两个路由键是变通办法。
|
||||
- **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。
|
||||
- **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
Provider,
|
||||
ThinkingLevelMap,
|
||||
} from '@earendil-works/pi-ai'
|
||||
import { anthropicApiRoot } from './endpoint.ts'
|
||||
|
||||
/**
|
||||
* Pricing for a model the installed catalog does not describe. The harness
|
||||
@@ -855,10 +856,11 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
|
||||
invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the`
|
||||
+ ' route\'s api to the wire protocol its endpoint speaks')
|
||||
}
|
||||
const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl
|
||||
if (baseUrl === undefined) {
|
||||
const configuredBaseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl
|
||||
if (configuredBaseUrl === undefined) {
|
||||
invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`)
|
||||
}
|
||||
const baseUrl = api === 'anthropic-messages' ? anthropicApiRoot(configuredBaseUrl) : configuredBaseUrl
|
||||
// Capacities fall back to the route's own defaults, so a model listing that
|
||||
// discloses nothing but ids still yields a serviceable route. The fallback
|
||||
// is a guess by construction, which is why it is a configurable route field
|
||||
|
||||
@@ -26,6 +26,7 @@ import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai
|
||||
import type { LlmDiscoveredModel, LlmModelDiscoveryOperation } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
|
||||
import { catalogModels } from './catalog.ts'
|
||||
import { anthropicApiRoot } from './endpoint.ts'
|
||||
|
||||
/**
|
||||
* Protocols whose model listing this module can read. OpenAI protocols use
|
||||
@@ -45,6 +46,9 @@ const LISTABLE_PROTOCOLS: ReadonlySet<string> = new Set([
|
||||
/** Stable API version required by Anthropic's model-listing endpoint. */
|
||||
const ANTHROPIC_VERSION = '2023-06-01'
|
||||
|
||||
/** Largest model-list page accepted by Anthropic's public endpoint. */
|
||||
const ANTHROPIC_MODEL_LIMIT = 1000
|
||||
|
||||
/**
|
||||
* Endpoint replies larger than this are refused. The endpoint is whatever URL
|
||||
* the user typed, so the ceiling holds on the bytes actually read rather than
|
||||
@@ -102,8 +106,8 @@ function label(...candidates: readonly unknown[]): string | undefined {
|
||||
*/
|
||||
function listingUrl(baseURL: string, api: string): string {
|
||||
const base = baseURL.replace(/\/+$/, '')
|
||||
if (api !== 'anthropic-messages' || base.endsWith('/v1')) return `${base}/models`
|
||||
return `${base}/v1/models`
|
||||
if (api !== 'anthropic-messages') return `${base}/models`
|
||||
return `${anthropicApiRoot(base)}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,11 +154,13 @@ async function readBounded(response: Response, url: string): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one OpenAI-compatible listing reply. The standard `data` array takes
|
||||
* Read one supported model-listing reply. The standard `data` array takes
|
||||
* precedence when both supported formats are present. An enriched `models`
|
||||
* map uses each property key as the endpoint-facing id; its nested `id` is
|
||||
* only a fallback for an empty key because gateways may put a canonical model
|
||||
* identity there instead of the alias they accept on requests.
|
||||
* identity there instead of the alias they accept on requests. Only
|
||||
* object-valued map entries are models; primitive properties are ignored
|
||||
* because they may be directory metadata rather than model records.
|
||||
*
|
||||
* Entries without a usable id are skipped rather than failing the whole
|
||||
* interrogation: a single malformed row should not deny the user the rest of
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Endpoint normalization shared by pi-ai model discovery and inference.
|
||||
*
|
||||
* @module dsh-llm-pi-ai/endpoint
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return the API root expected by the Anthropic SDK.
|
||||
*
|
||||
* Anthropic resource methods append `/v1/...` themselves. Accepting a user
|
||||
* address that already ends in `/v1` therefore requires removing that suffix
|
||||
* before model routing, while discovery appends its own native listing path to
|
||||
* the same root.
|
||||
* @param baseURL - configured Anthropic endpoint, with or without `/v1`.
|
||||
* @returns the endpoint root without trailing slashes or a terminal `/v1`.
|
||||
*/
|
||||
export function anthropicApiRoot(baseURL: string): string {
|
||||
const base = baseURL.replace(/\/+$/, '')
|
||||
return base.endsWith('/v1') ? base.slice(0, -3) : base
|
||||
}
|
||||
@@ -95,6 +95,39 @@ describe('hand-declared providers', () => {
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer test-key')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['', '/v1/messages'],
|
||||
['/v1', '/v1/messages'],
|
||||
['/tenant/v1', '/tenant/v1/messages'],
|
||||
])('routes an Anthropic base ending in %s without duplicating its API version', async (suffix, path) => {
|
||||
const server = await mockServer([{
|
||||
status: 400,
|
||||
body: JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'stop' } }),
|
||||
}])
|
||||
const ctx = await harness({
|
||||
providers: {
|
||||
'acme-anthropic': {
|
||||
apiKeyEnv: KEY_ENV,
|
||||
api: 'anthropic-messages',
|
||||
baseURL: `${server.url}${suffix}`,
|
||||
models: [{ id: 'claude-test', contextWindow: 200_000, maxTokens: 4096 }],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await assemble(ctx, {
|
||||
provider: 'acme-anthropic',
|
||||
model: 'claude-test',
|
||||
messages: [createUserMessage({
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})],
|
||||
})
|
||||
|
||||
expect(result.finish.kind).toBe('error')
|
||||
expect(server.paths).toEqual([path])
|
||||
})
|
||||
|
||||
it('lists and resolves the declared models rather than a catalog', async () => {
|
||||
const server = await mockServer([])
|
||||
const ctx = await harness(gateway(`${server.url}/v1`))
|
||||
|
||||
@@ -146,6 +146,7 @@ describe('draft-provider model discovery', () => {
|
||||
'bare-route': {},
|
||||
'': { id: 'nested-id', display_name: 'Nested fallback' },
|
||||
'malformed-route': null,
|
||||
'primitive-route': 'not a model record',
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -197,7 +198,11 @@ describe('draft-provider model discovery', () => {
|
||||
{ id: 'claude-sonnet', name: 'Claude Sonnet', contextWindow: 200_000, maxTokens: 64_000 },
|
||||
])
|
||||
expect(versionedModels).toEqual(rootModels)
|
||||
expect(server.paths).toEqual(['/v1/models', '/v1/models', '/v1/models'])
|
||||
expect(server.paths).toEqual([
|
||||
'/v1/models?limit=1000',
|
||||
'/v1/models?limit=1000',
|
||||
'/v1/models?limit=1000',
|
||||
])
|
||||
expect(server.headers.map(headers => headers['x-api-key']))
|
||||
.toEqual(['anthropic-key', 'anthropic-key', undefined])
|
||||
expect(server.headers.map(headers => headers['anthropic-version']))
|
||||
|
||||
Reference in new issue
Block a user