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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user