From ccecf4db0417069ca806d8f1e07f7986b78614fa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 18:00:43 +0800 Subject: [PATCH] fix(llm): address model discovery review --- ...-provider-endpoint-interrogation.i18n.yaml | 4 +- ...4-draft-provider-endpoint-interrogation.md | 6 +-- ...raft-provider-endpoint-interrogation.zh.md | 6 +-- ...specific-model-listing-discovery.i18n.yaml | 6 +++ ...otocol-specific-model-listing-discovery.md | 39 +++++++++++++++++++ ...col-specific-model-listing-discovery.zh.md | 39 +++++++++++++++++++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 3 +- packages/llm/llm-pi-ai/README.zh.md | 3 +- packages/llm/llm-pi-ai/src/catalog.ts | 6 ++- packages/llm/llm-pi-ai/src/discovery.ts | 14 +++++-- packages/llm/llm-pi-ai/src/endpoint.ts | 20 ++++++++++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 33 ++++++++++++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 7 +++- 17 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md create mode 100644 .agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md create mode 100644 packages/llm/llm-pi-ai/src/endpoint.ts diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 2dd955f0e3..82f9d4ae6b 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 9f304ed1fb..a6d8d32f12 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index b689f76739..e162ddb307 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -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。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml new file mode 100644 index 0000000000..51508f91b9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md new file mode 100644 index 0000000000..a4fb9d3c0f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md new file mode 100644 index 0000000000..66e4dd7643 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md @@ -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 路径。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 5bf49a7f19..2d8accc664 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 6710646098..cac2785886 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -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 `_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. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 456cbc1ed7..7df96a9a1b 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -105,7 +105,7 @@ kind: "package-reference" - **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 -- **询问只覆盖 OpenAI 兼容端点**:在这些协议下,适配器接受标准 `data` 数组或富信息 `models` 对象;讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 +- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6bd2735f5a..5041fa7a5b 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -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 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index d1d70a99e8..a49bc39349 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -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`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index d324f29ab2..0d43672df3 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -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` 条目提供的占位凭据。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index a4b7d97ebe..9f0cd0e6c4 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -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 diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index cce139a68c..fa87cc1fe2 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -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 = 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 { } /** - * 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 diff --git a/packages/llm/llm-pi-ai/src/endpoint.ts b/packages/llm/llm-pi-ai/src/endpoint.ts new file mode 100644 index 0000000000..245ae6e712 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/endpoint.ts @@ -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 +} diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 83cee8e239..4d81ba1eaa 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -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`)) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index c95dd43a31..c844a250a2 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -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']))