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

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