The native adapter's route was named deepseek, colliding with pi-ai's catalog provider of the same name, so the two DeepSeek paths could never be mounted side by side. The web settings page needs both configurable at once. Compositions, fixtures, goldens, scaffolding defaults, and docs all move together (pre-release, no shim); TUI/session-query-spill/ missing-credential goldens re-recorded through their keyless refresh modes because provider-name length shifts box padding and spill truncation points.
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-official')).resolves.toHaveLength(2)
|
|
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
|
{ provider: 'deepseek-official', 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-official')).toEqual({
|
|
mode: 'always',
|
|
initialDelayMs: 25,
|
|
maxDelayMs: 100,
|
|
jitterRatio: 0.2,
|
|
})
|
|
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', 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-official')).resolves.toHaveLength(2)
|
|
await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
|
|
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
|
{ provider: 'deepseek-official', 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')
|
|
})
|
|
})
|