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.
212 lines
8.1 KiB
TypeScript
212 lines
8.1 KiB
TypeScript
import { createServer } from 'node:http'
|
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
|
|
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
|
|
|
const servers: Server[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
|
})
|
|
|
|
interface ListingServer {
|
|
url: string
|
|
paths: string[]
|
|
headers: IncomingMessage['headers'][]
|
|
}
|
|
|
|
/**
|
|
* A stand-in provider that answers one scripted `GET /models`. `chunks` writes
|
|
* without a declared length, which is how a real streamed reply arrives.
|
|
*/
|
|
async function listingServer(behavior: {
|
|
status?: number
|
|
body?: string
|
|
chunks?: string[]
|
|
}): Promise<ListingServer> {
|
|
const paths: string[] = []
|
|
const headers: IncomingMessage['headers'][] = []
|
|
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
|
paths.push(request.url ?? '')
|
|
headers.push(request.headers)
|
|
if (behavior.chunks !== undefined) {
|
|
// No declared length: the ceiling has to hold on what is read.
|
|
response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' })
|
|
for (const chunk of behavior.chunks) response.write(chunk)
|
|
response.end()
|
|
return
|
|
}
|
|
const body = behavior.body ?? '{}'
|
|
response.writeHead(behavior.status ?? 200, {
|
|
'content-type': 'application/json',
|
|
'content-length': String(Buffer.byteLength(body)),
|
|
})
|
|
response.end(body)
|
|
})
|
|
servers.push(server)
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
const address = server.address()
|
|
if (address === null || typeof address === 'string') throw new Error('no port')
|
|
return { url: `http://127.0.0.1:${address.port}`, paths, headers }
|
|
}
|
|
|
|
/** A bare dormant mount: discovery is offered whether or not a route exists. */
|
|
async function harness(): Promise<Context> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, {})
|
|
return ctx
|
|
}
|
|
|
|
describe('draft-provider model discovery', () => {
|
|
it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => {
|
|
const server = await listingServer({
|
|
body: JSON.stringify({
|
|
data: [
|
|
{ id: 'acme-large', display_name: 'Acme Large', context_length: 65_536, max_output_tokens: 4096 },
|
|
{ id: 'acme-small' },
|
|
],
|
|
}),
|
|
})
|
|
const ctx = await harness()
|
|
|
|
const models = await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/v1`, apiKey: 'probe-key' })
|
|
|
|
expect(models).toEqual([
|
|
{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 },
|
|
{ id: 'acme-small' },
|
|
])
|
|
expect(server.paths).toEqual(['/v1/models'])
|
|
expect(server.headers[0]?.authorization).toBe('Bearer probe-key')
|
|
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
|
|
})
|
|
|
|
it('keeps a deployment path instead of resolving it away', async () => {
|
|
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
|
|
const ctx = await harness()
|
|
|
|
await ctx.llm.discoverModels('llm-pi-ai', { baseURL: `${server.url}/openai/v1/` })
|
|
|
|
expect(server.paths).toEqual(['/openai/v1/models'])
|
|
})
|
|
|
|
it('offers no credential when the draft names none', async () => {
|
|
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) })
|
|
const ctx = await harness()
|
|
|
|
await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })
|
|
|
|
expect(server.headers[0]?.authorization).toBeUndefined()
|
|
})
|
|
|
|
it('drops unusable rows rather than failing the whole listing', async () => {
|
|
const server = await listingServer({
|
|
body: JSON.stringify({
|
|
data: [
|
|
{ id: 'good' },
|
|
{ id: '' },
|
|
{ name: 'no id at all' },
|
|
null,
|
|
{ id: 'good' },
|
|
{ id: 'zero-capacity', context_length: 0, max_tokens: -1 },
|
|
],
|
|
}),
|
|
})
|
|
const ctx = await harness()
|
|
|
|
expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
|
|
.toEqual([{ id: 'good' }, { id: 'zero-capacity' }])
|
|
})
|
|
|
|
it('points at the credential for a rejected one, and only then', async () => {
|
|
const ctx = await harness()
|
|
|
|
for (const status of [401, 403]) {
|
|
const refused = await listingServer({ status, body: '{"error":"nope"}' })
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: refused.url, apiKey: 'wrong' }))
|
|
.rejects.toThrow(new RegExp(`answered ${status}; check the API key`))
|
|
}
|
|
|
|
// A server fault is not a credential problem, so it must not send the user
|
|
// off to re-check a key that is fine.
|
|
const broken = await listingServer({ status: 500, body: '{"error":"boom"}' })
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url, apiKey: 'fine' }))
|
|
.rejects.toThrow(/answered 500$/)
|
|
})
|
|
|
|
it('reports a reply that is not a model listing', async () => {
|
|
const server = await listingServer({ body: '{"models":[]}' })
|
|
const ctx = await harness()
|
|
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url }))
|
|
.rejects.toThrow(/no "data" array; enter this provider's models by hand/)
|
|
|
|
const broken = await listingServer({ body: 'not json at all' })
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url }))
|
|
.rejects.toThrow(/did not answer with JSON/)
|
|
})
|
|
|
|
it('refuses an oversized reply, whether its length is declared or streamed', async () => {
|
|
const ctx = await harness()
|
|
// Just over the four-megabyte ceiling, as one padded model row.
|
|
const oversized = `{"data":[{"id":"m","pad":"${'x'.repeat(4 * 1024 * 1024)}"}]}`
|
|
|
|
const declared = await listingServer({ body: oversized })
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: declared.url }))
|
|
.rejects.toThrow(/answered with more than 4194304 bytes/)
|
|
|
|
// A streamed reply declares no length, so the ceiling has to hold on the
|
|
// body the harness actually read.
|
|
const streamed = await listingServer({ chunks: ['{"data":[{"id":"m","pad":"', 'x'.repeat(4 * 1024 * 1024), '"}]}'] })
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: streamed.url }))
|
|
.rejects.toThrow(/answered with more than 4194304 bytes/)
|
|
})
|
|
|
|
it('reports an unreachable endpoint instead of an empty catalog', async () => {
|
|
const ctx = await harness()
|
|
// Port 9 is the discard service: nothing accepts a connection there.
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'http://127.0.0.1:9/v1' }))
|
|
.rejects.toMatchObject({ code: 'DISCOVERY_FAILED' })
|
|
})
|
|
|
|
it('says which protocols it cannot interrogate rather than guessing a shape', async () => {
|
|
const ctx = await harness()
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', {
|
|
baseURL: 'https://gateway.example/v1',
|
|
api: 'anthropic-messages',
|
|
})).rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' })
|
|
})
|
|
|
|
it('honors caller cancellation', async () => {
|
|
const ctx = await harness()
|
|
const aborted = AbortSignal.abort('test cancellation')
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', {
|
|
baseURL: 'http://127.0.0.1:9/v1',
|
|
signal: aborted,
|
|
})).rejects.toMatchObject({ code: 'ABORTED' })
|
|
})
|
|
|
|
it('is offered for the namespace, and refuses one it does not serve', async () => {
|
|
const ctx = await harness()
|
|
|
|
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai'])
|
|
await expect(ctx.llm.discoverModels('llm-deepseek', { baseURL: 'https://api.deepseek.com' }))
|
|
.rejects.toMatchObject({ code: 'NO_DISCOVERY' })
|
|
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: '' }))
|
|
.rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
|
|
})
|
|
|
|
it('withdraws the offer when the plugin unloads', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
const fiber = await ctx.plugin(LlmPiAi, {})
|
|
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual(['llm-pi-ai'])
|
|
|
|
await fiber.dispose()
|
|
|
|
expect(ctx.llm.listModelDiscoveryNamespaces()).toEqual([])
|
|
})
|
|
})
|