feat(llm): interrogate a draft provider endpoint for its models

Once a pi-ai route became a declaration rather than a catalog lookup,
adding an OpenAI-compatible gateway meant knowing its model ids up
front. Most such endpoints publish that list at `GET /models`, but no
seam operation could ask: every one is keyed by a registered provider
route, and the provider being added has no route, no stored profile,
and no stored credential — the endpoint and key are values in a form.

Interrogation is therefore keyed by settings namespace, which a
configuration surface already holds from the configurable-provider
directory. `registerModelDiscovery` offers it per namespace,
`discoverModels` asks, and the request carries the draft itself. The
reply is candidates, not a catalog: every field but the id is optional
because most listings disclose nothing else, and adopting one is a
settings write like any other. Nothing here reads or writes settings or
credentials, so `settings.yaml` still decides what a route serves.

`llm.discoverModels` carries the same draft over the wire. Its apiKey is
the third and last payload a secret may ride, and it is never stored,
logged, or echoed; every refusal folds into `model-discovery-failed`,
naming the endpoint asked but never the credential offered.

The pi-ai side is a plain GET for OpenAI-compatible protocols only —
their listing shape is the one gateways, self-hosted servers, and the
official endpoints agree on. Others say so, sending the user to
hand-entry rather than reporting a guessed shape as an empty provider.
The reply is read under a four-megabyte ceiling held on the bytes
actually received, because the endpoint is a URL the user typed.
This commit is contained in:
Yichen Jiang
2026-08-04 10:14:46 +08:00
parent 9948a37cbc
commit ecee93ec26
34 changed files with 985 additions and 18 deletions

View File

@@ -560,3 +560,89 @@ describe('llm domain', () => {
expect(frames).toEqual([{ type: 'host/models-changed' }, { type: 'host/models-changed' }])
})
})
describe('llm.discoverModels', () => {
it('carries a draft to its namespace and returns candidates without storing anything', async () => {
const ctx = await harness()
const seen: unknown[] = []
ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => {
seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey })
return Promise.resolve([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
})
const api = createApiProxy(ctx, DEFAULTS)
const value = expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})))
expect(value.models).toEqual([
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
{ id: 'acme-small' },
])
expect(seen).toEqual([{
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
}])
// Interrogating a draft is a read: no namespace gained a section, and no
// credential reference was written.
expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns))
.not.toContain('llm-pi-ai')
})
it('omits a credential and protocol the draft does not name', async () => {
const ctx = await harness()
let probe: unknown
ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
probe = request_
return Promise.resolve([])
})
const api = createApiProxy(ctx, DEFAULTS)
expectOk(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
})))
// Absent fields stay absent rather than crossing as explicit undefined:
// the adapter distinguishes "no protocol named" from "protocol undefined".
expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' })
})
it('reports a failed interrogation as the form\'s next move, naming no credential', async () => {
const ctx = await harness()
ctx.llm.registerModelDiscovery('llm-pi-ai', () =>
Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key')))
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.llm.discoverModels(request({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
apiKey: 'wrong',
})))
expect(error.code).toBe('model-discovery-failed')
expect(error.message).toContain('answered 401; check the API key')
expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' })
expect(JSON.stringify(error)).not.toContain('wrong')
})
it('reports a namespace no adapter family serves', async () => {
const ctx = await harness()
const api = createApiProxy(ctx, DEFAULTS)
const error = expectErr(await api.llm.discoverModels(request({
settingsNs: 'llm-deepseek',
baseURL: 'https://api.deepseek.com',
})))
expect(error.code).toBe('model-discovery-failed')
expect(error.message).toContain('no model discovery is registered')
})
})

View File

@@ -112,6 +112,7 @@ function scriptedApi(overrides: {
llm: {
providers: r => ok(r, { providers: [] }),
models: r => ok(r, { groups: [], failures: [] }),
discoverModels: err,
...overrides.llm,
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
@@ -694,6 +695,7 @@ describe('config unary surface', () => {
llm: {
providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
},
})
const c = client(api)
@@ -719,16 +721,31 @@ describe('config unary surface', () => {
expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
const models = await c.llm.models({})
expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
const discovered = await c.llm.discoverModels({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})
expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } })
expect(seen.map(call => call.method)).toEqual([
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'llm.providers', 'llm.models',
'llm.providers', 'llm.models', 'llm.discoverModels',
])
expect(seen[2]?.payload).toEqual({ ns: 'llm-deepseek', patch: { baseURL: 'https://next' } })
expect(seen[4]?.payload)
.toEqual({ ns: 'llm-deepseek', ops: [{ op: 'unset', path: ['baseURL'] }], expectedRevision: 0 })
expect(seen[6]?.payload).toEqual({ ref: 'OPENAI_API_KEY', value: 'sk-x' })
// The draft crosses whole, credential included: the host needs it for this
// one interrogation and stores none of it.
expect(seen[10]?.payload).toEqual({
settingsNs: 'llm-pi-ai',
baseURL: 'https://gateway.acme.example/v1',
api: 'openai-completions',
apiKey: 'probe-key',
})
})
it('rejects an invalid credential reference name at the carrier boundary', async () => {

View File

@@ -253,6 +253,9 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async models(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } }
},
async discoverModels(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } }
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),