fix(llm): let an interrogation use the credential its route already stored

A configuration surface never holds a stored secret — it edits a redacted
descriptor — so once a key is saved, the draft it sends carries the route and
the endpoint and no credential at all. The interrogation went out
unauthenticated and the endpoint's 401 came back as "check the API key",
pointing at the one thing that was fine.

A named route now supplies its own credential, resolved exactly as a request
to it would be. A key typed into the form still wins: it is the one under
test, and may be the replacement for the stored one that is failing.

Resolution is a callback the probe invokes past the catalog short-circuit and
the protocol check, so a route answered from the installed registry costs no
credential lookup — and cannot fail over a credential the question never
needed.
This commit is contained in:
Yichen Jiang
2026-08-05 20:55:39 +08:00
parent b2d0e8972f
commit 66c2cb81d3
9 changed files with 94 additions and 15 deletions

View File

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

View File

@@ -85,6 +85,8 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ
A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand.
A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all.
Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `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.
Most listings disclose an id and nothing else; `context_window`/`context_length` and `max_output_tokens`/`max_tokens` are read when a gateway supplies them, entries without a usable id are skipped rather than failing the whole listing, and everything else the adopting surface still owes. 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 length is checked first but never trusted as the bound. An unreachable endpoint, a refused credential, a non-JSON body, and a body with no `data` array all fail with `DISCOVERY_FAILED` and a message naming the endpoint and, for a 401 or 403 alone, the credential. Cancellation during the body read surfaces as `ABORTED`, like a cancellation before the request went out.

View File

@@ -85,6 +85,8 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog而不是扩
点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答完全不联网pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。
草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey``apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。
询问只读 `openai-completions``openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。
多数列表只公布 id`context_window`/`context_length``max_output_tokens`/`max_tokens` 在网关提供时会被读取,没有可用 id 的条目会被跳过而不是让整份列表失败,其余仍由采纳方补齐。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL因此会先看声明长度但绝不把它当作边界。端点不可达、凭据被拒、响应非 JSON、以及响应没有 `data` 数组,都会以 `DISCOVERY_FAILED` 失败,消息点名端点;仅当 401 或 403 时才点名凭据。读取响应体期间被取消会呈现为 `ABORTED`,与请求发出之前被取消一致。

View File

@@ -164,12 +164,18 @@ function readListing(body: unknown): LlmDiscoveredModel[] {
/**
* Interrogate one draft provider endpoint for the models it advertises.
* @param request - the endpoint, protocol, and one-shot credential to use.
* @param storedApiKey - the credential the named route already stored, asked
* for only when the draft carries none and only on the path that reaches the
* network. A configuration surface never holds a stored secret — it edits a
* redacted descriptor — so without this an already-configured route would be
* interrogated unauthenticated and answer 401.
* @returns the advertised models in endpoint order.
* @throws LlmError when the protocol has no readable listing, the endpoint
* refuses or fails the request, or the reply is not a model listing.
*/
export async function discoverModels(
request: LlmModelDiscoveryRequest,
storedApiKey?: () => Promise<string | undefined>,
): Promise<readonly LlmDiscoveredModel[]> {
// A catalog route already has its answer, and a better one: the installed
// entries carry context windows and output caps no listing endpoint reports.
@@ -205,13 +211,19 @@ export async function discoverModels(
)
}
const url = listingUrl(request.baseURL)
// A key typed into the form wins: it is the one the user is testing, and it
// may be the replacement for exactly the stored key that is failing. The
// stored one is only asked for here, past the catalog short-circuit and the
// protocol check, so a route answered from the registry costs no credential
// lookup — and no diagnostic about a credential it never needed.
const apiKey = request.apiKey ?? await storedApiKey?.()
let response: Response
try {
response = await fetch(url, {
method: 'GET',
headers: {
accept: 'application/json',
...request.apiKey === undefined ? {} : { authorization: `Bearer ${request.apiKey}` },
...apiKey === undefined ? {} : { authorization: `Bearer ${apiKey}` },
...attributionHeaders(),
},
...request.signal === undefined ? {} : { signal: request.signal },

View File

@@ -177,10 +177,26 @@ export function apply(ctx: Context, config: Config): void {
directoryFacts = entries
}
ensureDirectory()
/**
* The credential a named route already resolves, for an interrogation whose
* draft carries none. A route being declared for the first time names no
* profile yet, and a profile that names no credential defers to pi-ai's own
* discovery, so both answer `undefined` and the endpoint is asked
* unauthenticated — the same posture a request to that route would take.
*/
const storedApiKey = async (provider: string | undefined): Promise<string | undefined> => {
if (provider === undefined) return undefined
const profile = profiles().get(provider)
if (profile === undefined) return undefined
return resolveApiKey(provider, profile)
}
// Interrogating an endpoint is a configuration-time action over a draft, so
// it is offered for the whole namespace rather than per route: the provider
// a surface is adding does not exist yet.
ctx.llm.registerModelDiscovery(NS, discoverModels)
// a surface is adding does not exist yet. The draft is the whole request
// except the credential: a configuration surface edits a redacted descriptor
// and never holds a stored secret, so an already-configured route supplies
// its own here rather than being interrogated unauthenticated.
ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider)))
// Route effects bind to this apply fiber via the stable `ctx` reference,
// even when a swap runs inside the scoped settings callback below. A bare
// mount (zero routes) is the dormant posture: nothing registers until a

View File

@@ -8,8 +8,11 @@ import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { discoverModels } from '../src/discovery.ts'
const servers: Server[] = []
/** Credential variables a test set, cleared so the next one starts unset. */
const touchedEnv: string[] = []
afterEach(async () => {
for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name)
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
})
@@ -140,6 +143,50 @@ describe('draft-provider model discovery', () => {
expect(server.headers[0]?.authorization).toBeUndefined()
})
it('authenticates a configured route the draft cannot supply a key for', async () => {
// What the Models page actually sends after a key is saved: the form holds
// the redacted descriptor, so the draft names the route and the endpoint
// and no credential at all. Interrogating unauthenticated would answer 401
// and read as a wrong key.
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
const ctx = new Context()
await ctx.plugin(LlmService)
process.env['ACME_GATEWAY_KEY'] = 'stored-key'
touchedEnv.push('ACME_GATEWAY_KEY')
await ctx.plugin(LlmPiAi, {
providers: {
'acme-gateway': {
apiKeyEnv: 'ACME_GATEWAY_KEY',
api: 'openai-completions',
baseURL: server.url,
models: [{ id: 'acme-large' }],
},
},
})
await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url })
// A key typed into the form is the one being tested — possibly the
// replacement for the stored one — so it wins.
await ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: server.url, apiKey: 'typed' })
// A route no profile declares yet is the create case: nothing is stored.
await ctx.llm.discoverModels('llm-pi-ai', { provider: 'not-declared-yet', baseURL: server.url })
expect(server.headers.map(headers => headers.authorization))
.toEqual(['Bearer stored-key', 'Bearer typed', undefined])
})
it('leaves a catalog route\'s credential unresolved, having never reached the network', async () => {
// The catalog answers before any endpoint is asked, so a route whose
// profile names a credential that is not set must still answer rather than
// failing over a key the interrogation never needed.
const ctx = new Context()
await ctx.plugin(LlmService)
Reflect.deleteProperty(process.env, 'ABSENT_FOR_DISCOVERY')
await ctx.plugin(LlmPiAi, { providers: { deepseek: { apiKeyEnv: 'ABSENT_FOR_DISCOVERY' } } })
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0)
})
it('drops unusable rows rather than failing the whole listing', async () => {
const server = await listingServer({
body: JSON.stringify({