|
|
@@ -0,0 +1,211 @@
|
|
|
+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([])
|
|
|
+ })
|
|
|
+})
|