Просмотр исходного кода

fix(llm): scope Anthropic /v1 handling to model discovery

Model requests hand the configured baseURL to pi-ai unchanged. Only the
discovery listing URL accepts the Anthropic API root with or without a
trailing /v1, because gateway documentation publishes both spellings.
Yichen Jiang 1 месяц назад
Родитель
Сommit
8179d929ab

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.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-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
+2026-09-02-protocol-specific-model-listing-discovery.md: 187c27cc997f1dd8b4d57bc72589c314c9be0537
+2026-09-02-protocol-specific-model-listing-discovery.zh.md: 176a1493986c84e35af2a704fc28cb06535d297e

+ 7 - 3
.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md

@@ -14,7 +14,7 @@ One gateway could be made to return an OpenAI-style array by sending an OpenAI S
 
 `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`.
+Anthropic SDK resource methods append `/v1` themselves, and gateway documentation publishes the API root both with and without that suffix. The listing URL therefore treats a drafted Anthropic `baseURL` ending in `/v1` as the same API root as the address without it, and it is the only place that segment is normalized: model requests receive the configured `baseURL` unchanged, exactly as pi-ai handles it. Deployment path prefixes remain intact: `https://gateway.example/tenant/v1` and `https://gateway.example/tenant` both list at `/tenant/v1/models`.
 
 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.
 
@@ -24,16 +24,20 @@ The parser normalizes the supported name and capacity spellings into `LlmDiscove
 
 **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.
 
+**Normalize the inference base as well.** Stripping the same `/v1` segment before model routing would let a `/v1` address both list and serve, but it moves request URL rules out of pi-ai and into this package for one protocol. Model requests keep pi-ai's own handling of `baseURL`; the listing request is the only URL this package builds.
+
+**Refuse a trailing `/v1`.** A load-time or discovery-time rejection would name the mistake early, but gateway documentation publishes the `/v1` spelling, so a user pasting a documented address would be turned away from a listing that works.
+
 **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 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 list in either root or `/v1` form; model requests use the configured address as pi-ai receives it.
 
 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.
+Local HTTP-server tests pin both accepted response formats, field normalization, name fallback, ignored malformed entries, Anthropic headers, the maximum-page query, and both spellings of the Anthropic root.

+ 7 - 3
.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 `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` 发送消息。
+Anthropic SDK 资源方法会自行追加 `/v1`,而网关文档会同时发布带与不带该后缀的 API 根地址。因此列表 URL 会把末尾为 `/v1` 的 Anthropic `baseURL` 草稿视为与不带该后缀的地址相同的 API 根地址,并且只有它会归一化这一段:模型请求收到的仍是配置原样的 `baseURL`,与 pi-ai 的处理完全一致。部署路径前缀会保留:`https://gateway.example/tenant/v1` 与 `https://gateway.example/tenant` 都在 `/tenant/v1/models` 列表。
 
 解析器接受 `data` 数组或富信息 `models` 对象,并在数组存在时优先使用它。数组条目使用自身的 `id`;对象条目使用属性键,因为嵌套 `id` 可能指向规范模型,而不是请求所接受的路由别名。只有值为对象的映射条目才视为模型,因此原始类型的目录元数据不会意外成为候选。空属性键才会回退到嵌套 `id`。
 
@@ -24,16 +24,20 @@ Anthropic SDK 资源方法会自行追加 `/v1`。因此,模型发现与推理
 
 **跟随 Anthropic 的所有页面。** 游标遍历可以返回超过 1,000 个条目的列表,但会给配置操作增加多请求失败、取消、游标推进与总大小处理。实现请求 Anthropic 的最大页面,并记录剩余截断限制。
 
+**同时归一化推理地址。** 在模型路由前截掉同一段 `/v1` 可以让 `/v1` 地址既能列表也能服务,但这会把请求 URL 规则从 pi-ai 挪进本包,且只为一种协议。模型请求保持 pi-ai 自身对 `baseURL` 的处理;列表请求是本包构造的唯一 URL。
+
+**拒绝末尾的 `/v1`。** 在加载或探测时拒绝可以尽早点出错误,但网关文档发布的就是 `/v1` 写法,照文档粘贴地址的用户会被一个本能工作的列表拒之门外。
+
 **为模型发现发送 OpenAI SDK `User-Agent`。** 这会让一个网关返回 `data`,但会错误标记 Harness 流量,并依赖未记录的客户端名称分支。读取两种已知响应格式可以保持归属准确。
 
 **采纳 `models` 对象的每个属性。** 原始类型属性不能证明其键是模型 id,也可能是数量或状态等目录元数据。把条目限制为记录可避免虚构模型候选。
 
 ## 后果
 
-Models 页面无需改变请求身份,即可询问 OpenAI 兼容网关与 Anthropic Messages 端点。发现的候选会在端点提供时携带路由 id、名称、上下文窗口与最大输出 token 数,只有 id 的列表也会通过 id 回退获得可编辑标签。Anthropic 地址以根地址或 `/v1` 形式配置时,模型发现与推理都能工作。
+Models 页面无需改变请求身份,即可询问 OpenAI 兼容网关与 Anthropic Messages 端点。发现的候选会在端点提供时携带路由 id、名称、上下文窗口与最大输出 token 数,只有 id 的列表也会通过 id 回退获得可编辑标签。Anthropic 地址以根地址或 `/v1` 形式都能列表;模型请求使用 pi-ai 收到的配置地址。
 
 受支持格式仍是显式兼容集合,而不是任意 JSON 推断。可见模型超过 1,000 个的 Anthropic 账户需要手工录入第一页之外的条目,原始类型的 `models` 属性会被忽略。
 
 ## 测试
 
-本地 HTTP 服务器测试钉住两种受支持响应格式、字段归一化、名称回退、忽略畸形条目、Anthropic 标头与最大页查询。提供方测试通过 pi-ai 驱动 Anthropic 请求,并证明根地址、`/v1` 地址和带前缀的 `/v1` 地址只到达一个带版本的 Messages 路径。
+本地 HTTP 服务器测试钉住两种受支持响应格式、字段归一化、名称回退、忽略畸形条目、Anthropic 标头、最大页查询,以及 Anthropic 根地址的两种写法。

+ 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: a49bc393495447e8fa4323c0530de1db860f5570
-README.zh.md: 0d43672df325a8f6c0526db77119df00a8ba51d6
+README.md: c0aacbe3e0b727cd7b6b163814dc96859b954b9c
+README.zh.md: 7b8cef7db2ba5cebe3e42345b308716ae0099c03

+ 1 - 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?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.
+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`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. 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
 

+ 1 - 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?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` 仍然是决定路由服务内容的唯一事实。
+插件会回答"该提供方可以提供哪些模型?",供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。
 
 ### 失败与恢复
 

+ 2 - 4
packages/llm/llm-pi-ai/src/catalog.ts

@@ -28,7 +28,6 @@ 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
@@ -856,11 +855,10 @@ 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 configuredBaseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl
-    if (configuredBaseUrl === undefined) {
+    const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl
+    if (baseUrl === 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

+ 12 - 7
packages/llm/llm-pi-ai/src/discovery.ts

@@ -26,7 +26,6 @@ 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
@@ -46,7 +45,7 @@ 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. */
+/** Largest model-list page accepted by Anthropic's public endpoint; discovery reads one page and does not follow `has_more`. */
 const ANTHROPIC_MODEL_LIMIT = 1000
 
 /**
@@ -99,15 +98,21 @@ function label(...candidates: readonly unknown[]): string | undefined {
 }
 
 /**
- * Join the endpoint base with the listing path. The base 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 instead of losing
- * them to `URL` resolution.
+ * Join the endpoint base with the protocol's listing path. The base 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 instead
+ * of losing them to `URL` resolution. OpenAI protocols list at
+ * `{baseURL}/models`. Anthropic lists at `{root}/v1/models`, where the root is
+ * the base without trailing slashes and without one trailing `/v1` segment:
+ * gateway documentation publishes both spellings of the same root. Only this
+ * listing URL normalizes that segment; model requests receive the configured
+ * `baseURL` unchanged.
  */
 function listingUrl(baseURL: string, api: string): string {
   const base = baseURL.replace(/\/+$/, '')
   if (api !== 'anthropic-messages') return `${base}/models`
-  return `${anthropicApiRoot(base)}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}`
+  const root = base.endsWith('/v1') ? base.slice(0, -3) : base
+  return `${root}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}`
 }
 
 /**

+ 0 - 20
packages/llm/llm-pi-ai/src/endpoint.ts

@@ -1,20 +0,0 @@
-/**
- * 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
-}

+ 0 - 33
packages/llm/llm-pi-ai/tests/catalog.spec.ts

@@ -95,39 +95,6 @@ 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`))