Forráskód Böngészése

fix(llm): address model discovery review

Yichen Jiang 2 hete
szülő
commit
ccecf4db04

+ 2 - 2
.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

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 0 - 0
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md


+ 6 - 0
.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

+ 39 - 0
.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.

+ 39 - 0
.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 路径。

+ 2 - 2
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

+ 1 - 1
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 `<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>

+ 1 - 1
packages/client/ui-settings-models/README.zh.md

@@ -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 - 2
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

+ 2 - 1
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`.

+ 2 - 1
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` 条目提供的占位凭据。

+ 4 - 2
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

+ 10 - 4
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<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

+ 20 - 0
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
+}

+ 33 - 0
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`))

+ 6 - 1
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']))

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott