164 lines
6.6 KiB
TypeScript
164 lines
6.6 KiB
TypeScript
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||
|
|
import { Context } from 'cordis'
|
||
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||
|
|
import { tmpdir } from 'node:os'
|
||
|
|
import { join } from 'node:path'
|
||
|
|
import LlmService from '@deepseek-ai/dsh-llm'
|
||
|
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||
|
|
import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
|
||
|
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||
|
|
import { SettingsLocal } from '@deepseek-ai/dsh-settings-local'
|
||
|
|
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||
|
|
import { assemble } from './assemble.ts'
|
||
|
|
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||
|
|
|
||
|
|
const NS = settingsNamespace('llm-deepseek')
|
||
|
|
const KEY_REF = credentialRef('DEEPSEEK_API_KEY')
|
||
|
|
|
||
|
|
const cleanups: Array<() => Promise<void>> = []
|
||
|
|
|
||
|
|
afterEach(async () => {
|
||
|
|
while (cleanups.length > 0) await cleanups.pop()!()
|
||
|
|
await closeMockServers()
|
||
|
|
vi.unstubAllEnvs()
|
||
|
|
})
|
||
|
|
|
||
|
|
async function home(): Promise<string> {
|
||
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-llm-dynamic-'))
|
||
|
|
cleanups.push(() => rm(dir, { recursive: true, force: true }))
|
||
|
|
return dir
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Harness {
|
||
|
|
ctx: Context
|
||
|
|
settingsFiber: { dispose(): Promise<void> }
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Real dynamic composition: llm + settings-local + credentials-local +
|
||
|
|
* llm-deepseek over one temp harness home. `watch: false` keeps every change
|
||
|
|
* flowing through the in-process write path, which is deterministic; external
|
||
|
|
* file watching is the providers' own covered concern.
|
||
|
|
*/
|
||
|
|
async function boot(dir: string, config: object): Promise<Harness> {
|
||
|
|
const ctx = new Context()
|
||
|
|
cleanups.push(async () => {
|
||
|
|
await ctx.fiber.dispose()
|
||
|
|
})
|
||
|
|
await ctx.plugin(LlmService)
|
||
|
|
const settingsFiber = ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
|
||
|
|
await settingsFiber
|
||
|
|
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||
|
|
await ctx.plugin(LlmDeepSeek, config)
|
||
|
|
return { ctx, settingsFiber }
|
||
|
|
}
|
||
|
|
|
||
|
|
function prompt(ctx: Context) {
|
||
|
|
return assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('request-level dynamic configuration', () => {
|
||
|
|
it('routes the next request with the freshly resolved base URL and credential', async () => {
|
||
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||
|
|
const dir = await home()
|
||
|
|
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=first-key\n')
|
||
|
|
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
||
|
|
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
||
|
|
const { ctx } = await boot(dir, { baseURL: serverA.url })
|
||
|
|
|
||
|
|
await prompt(ctx)
|
||
|
|
expect(serverA.headers[0]?.authorization).toBe('Bearer first-key')
|
||
|
|
|
||
|
|
await ctx.settings.update(NS, { baseURL: serverB.url })
|
||
|
|
await ctx.credentials.set(KEY_REF, 'second-key')
|
||
|
|
|
||
|
|
await prompt(ctx)
|
||
|
|
// No restart, no re-registration: the next request resolved both facts.
|
||
|
|
expect(serverA.requests).toHaveLength(1)
|
||
|
|
expect(serverB.headers[0]?.authorization).toBe('Bearer second-key')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('prefers a literal settings apiKey over the credential layers', async () => {
|
||
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||
|
|
const dir = await home()
|
||
|
|
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=file-key\n')
|
||
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||
|
|
const { ctx } = await boot(dir, { baseURL: server.url })
|
||
|
|
|
||
|
|
await ctx.settings.update(NS, { apiKey: 'literal-key' })
|
||
|
|
await prompt(ctx)
|
||
|
|
expect(server.headers[0]?.authorization).toBe('Bearer literal-key')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('starts keyless and serves the next request once the key arrives', async () => {
|
||
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||
|
|
const dir = await home()
|
||
|
|
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||
|
|
const { ctx } = await boot(dir, { baseURL: server.url })
|
||
|
|
|
||
|
|
await expect(prompt(ctx)).rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
||
|
|
await ctx.credentials.set(KEY_REF, 'sk-arrived')
|
||
|
|
await prompt(ctx)
|
||
|
|
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('advertises a live settings catalog without re-registration', async () => {
|
||
|
|
const dir = await home()
|
||
|
|
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||
|
|
|
||
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||
|
|
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
|
||
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||
|
|
{ provider: 'deepseek', id: 'settings-model', name: 'From Settings' },
|
||
|
|
])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('re-registers the route in place when the captured retry policy changes', async () => {
|
||
|
|
const dir = await home()
|
||
|
|
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||
|
|
|
||
|
|
await ctx.settings.update(NS, {
|
||
|
|
retryPolicy: { mode: 'always', backoff: { initialDelayMs: 25, maxDelayMs: 100, jitterRatio: 0.2 } },
|
||
|
|
})
|
||
|
|
expect(ctx.llm.providerRetryPolicy('deepseek')).toEqual({
|
||
|
|
mode: 'always',
|
||
|
|
initialDelayMs: 25,
|
||
|
|
maxDelayMs: 100,
|
||
|
|
jitterRatio: 0.2,
|
||
|
|
})
|
||
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => {
|
||
|
|
const dir = await home()
|
||
|
|
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||
|
|
|
||
|
|
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
|
||
|
|
// schema and fail the explicit resolve step.
|
||
|
|
await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] })
|
||
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||
|
|
await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
|
||
|
|
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||
|
|
{ provider: 'deepseek', id: 'recovered', name: 'recovered' },
|
||
|
|
])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('falls back to the composition entry when settings detach', async () => {
|
||
|
|
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||
|
|
const dir = await home()
|
||
|
|
await writeFile(join(dir, '.env'), 'DEEPSEEK_API_KEY=steady-key\n')
|
||
|
|
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
||
|
|
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
||
|
|
const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })
|
||
|
|
|
||
|
|
await ctx.settings.update(NS, { baseURL: serverB.url })
|
||
|
|
await prompt(ctx)
|
||
|
|
expect(serverB.requests).toHaveLength(1)
|
||
|
|
|
||
|
|
await settingsFiber.dispose()
|
||
|
|
await prompt(ctx)
|
||
|
|
expect(serverA.requests).toHaveLength(1)
|
||
|
|
expect(serverA.headers[0]?.authorization).toBe('Bearer steady-key')
|
||
|
|
})
|
||
|
|
})
|