feat(llm-deepseek): per-request connection resolution over settings + credentials
The adapter takes an options thunk and a per-stream credential resolver instead of frozen construction facts: base URL, catalog, defaults, idle budget, and the API key re-resolve at each operation, so a settings or credential change reaches the very next request while in-flight streams keep the facts they started with. resolveAdapterOptions is the one explicit resolve step (entry config fails loud at load; a live snapshot failing a beyond-schema bound keeps the last good options). The plugin layers its entry config under the optional llm-deepseek settings section and resolves keys literal-first through ctx.credentials with an ambient env fallback; a missing key now registers the route, warns, and fails each request with actionable MISSING_CREDENTIAL instead of failing plugin load. The registration-captured retry policy re-registers the route in place when it changes.
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { createUserMessage,
|
||||
@@ -14,90 +12,18 @@ import LlmService, { createUserMessage,
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter, resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { httpErrorCode } from '../src/adapter.ts'
|
||||
import { assemble } from './assemble.ts'
|
||||
|
||||
/** One scripted behavior for the next request the mock server receives. */
|
||||
type Behavior =
|
||||
| { kind: 'sse'; events: string[]; delayMs?: number }
|
||||
| { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
|
||||
| { kind: 'close-early'; events: string[] }
|
||||
|
||||
interface MockServer {
|
||||
url: string
|
||||
/** Bodies of received requests, in order. */
|
||||
requests: unknown[]
|
||||
/** Header bags of received requests, in order (parallel to `requests`). */
|
||||
headers: IncomingMessage['headers'][]
|
||||
script: Behavior[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const servers: Server[] = []
|
||||
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
||||
import type { Behavior } from './mock-server.ts'
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
await closeMockServers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Local chat-completions stand-in: replays scripted behaviors per request. */
|
||||
async function mockServer(script: Behavior[]): Promise<MockServer> {
|
||||
const requests: unknown[] = []
|
||||
const headers: IncomingMessage['headers'][] = []
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
let body = ''
|
||||
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
||||
request.on('end', () => {
|
||||
requests.push(JSON.parse(body))
|
||||
headers.push(request.headers)
|
||||
const behavior = script.shift()
|
||||
if (!behavior) {
|
||||
response.writeHead(500).end('mock script exhausted')
|
||||
return
|
||||
}
|
||||
if (behavior.kind === 'http-error') {
|
||||
response.writeHead(behavior.status, {
|
||||
'content-type': behavior.contentType ?? 'application/json',
|
||||
...behavior.headers,
|
||||
})
|
||||
response.end(behavior.body)
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const write = (index: number): void => {
|
||||
if (index >= behavior.events.length) {
|
||||
if (behavior.kind === 'sse') response.end()
|
||||
else response.destroy() // close-early: drop the socket mid-stream
|
||||
return
|
||||
}
|
||||
response.write(`data: ${behavior.events[index]}\n\n`)
|
||||
setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
|
||||
}
|
||||
write(0)
|
||||
})
|
||||
})
|
||||
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}`,
|
||||
requests,
|
||||
headers,
|
||||
script,
|
||||
close: () => new Promise(resolve => server.close(() => { resolve() })),
|
||||
}
|
||||
}
|
||||
|
||||
const textEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
|
||||
'{"choices":[{"delta":{"content":"hello"}}]}',
|
||||
'{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
async function harness(baseURL: string, config: object = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -105,6 +31,15 @@ async function harness(baseURL: string, config: object = {}) {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Direct adapter over the plugin's real resolve step, with a static key. */
|
||||
function adapterOf(config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {}): DeepSeekAdapter {
|
||||
const { apiKey, ...rest } = config
|
||||
return new DeepSeekAdapter({
|
||||
options: () => resolveAdapterOptions(rest),
|
||||
resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
|
||||
})
|
||||
}
|
||||
|
||||
describe('DeepSeekAdapter against a mock server', () => {
|
||||
it('streams a text generation end to end through the assembler', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
@@ -275,11 +210,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
'rejects direct adapter effort %s before I/O when thinking is disabled',
|
||||
async (effort) => {
|
||||
const server = await mockServer([])
|
||||
const adapter = new DeepSeekAdapter({
|
||||
apiKey: 'test-key',
|
||||
baseURL: server.url,
|
||||
defaults: { thinking: 'disabled' },
|
||||
})
|
||||
const adapter = adapterOf({ apiKey: 'test-key', baseURL: server.url, thinking: 'disabled' })
|
||||
|
||||
const stream = adapter.stream({
|
||||
provider: 'deepseek',
|
||||
@@ -483,7 +414,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
})
|
||||
|
||||
it('throws EMPTY_RESPONSE when the response has no body', async () => {
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
const adapter = adapterOf({ baseURL: 'http://127.0.0.1:1' })
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||
new Response(null, { status: 200 }),
|
||||
)
|
||||
@@ -538,7 +469,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
it('maps connection failures to TRANSPORT without losing the cause', async () => {
|
||||
const cause = new TypeError('connection refused')
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockRejectedValue(cause)
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
|
||||
const adapter = adapterOf({ baseURL: 'https://example.invalid' })
|
||||
try {
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
@@ -555,7 +486,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
failed.reject('offline')
|
||||
return failed.promise
|
||||
})
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'https://example.invalid' })
|
||||
const adapter = adapterOf({ baseURL: 'https://example.invalid' })
|
||||
try {
|
||||
const drain = async (): Promise<void> => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
@@ -585,11 +516,7 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
})
|
||||
return Promise.resolve(new Response(body, { status: 200 }))
|
||||
})
|
||||
const adapter = new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'https://example.invalid',
|
||||
streamIdleTimeoutMs: 100,
|
||||
})
|
||||
const adapter = adapterOf({ baseURL: 'https://example.invalid', streamIdleTimeoutMs: 100 })
|
||||
try {
|
||||
const drain = (async () => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
@@ -733,22 +660,15 @@ describe('plugin registration and config', () => {
|
||||
)
|
||||
|
||||
it.each(['high', 'max'] as const)(
|
||||
'rejects disabled-thinking effort %s at the direct constructor boundary',
|
||||
'rejects disabled-thinking effort %s at the resolver boundary',
|
||||
(reasoningEffort) => {
|
||||
expect(() => new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaults: { thinking: 'disabled', reasoningEffort },
|
||||
})).toThrow(/only reasoningEffort "off"/)
|
||||
expect(() => resolveAdapterOptions({ thinking: 'disabled', reasoningEffort }))
|
||||
.toThrow(/only reasoningEffort "off"/)
|
||||
},
|
||||
)
|
||||
|
||||
it('accepts disabled thinking with off at the direct constructor boundary', async () => {
|
||||
const adapter = new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaults: { thinking: 'disabled', reasoningEffort: 'off' },
|
||||
})
|
||||
it('accepts disabled thinking with off at the resolver boundary', async () => {
|
||||
const adapter = adapterOf({ thinking: 'disabled', reasoningEffort: 'off' })
|
||||
await expect(adapter.resolveModel('deepseek', 'pass-through')).resolves.toMatchObject({
|
||||
reasoning: {
|
||||
efforts: [{ id: ReasoningEffortId('off'), name: 'Off' }],
|
||||
@@ -863,11 +783,8 @@ describe('plugin registration and config', () => {
|
||||
it.each([0, 1.5])(
|
||||
'rejects invalid adapter-wide default context capacity %s',
|
||||
async (defaultContextWindow) => {
|
||||
expect(() => new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaultContextWindow,
|
||||
})).toThrow(/defaultContextWindow must be a positive integer/)
|
||||
expect(() => resolveAdapterOptions({ defaultContextWindow }))
|
||||
.toThrow(/defaultContextWindow must be a positive integer/)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
@@ -889,13 +806,19 @@ describe('plugin registration and config', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('throws a clear error when no API key is available', async () => {
|
||||
it('loads keyless, keeps the catalog browsable, and fails the request actionably', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {}))
|
||||
.rejects.toThrow(/an API key is required/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
||||
// First-boot onboarding: the route registers so models stay discoverable;
|
||||
// only the request itself needs a key.
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toMatchObject({ code: 'MISSING_CREDENTIAL' })
|
||||
await expect(assemble(ctx, { model: 'deepseek-v4-flash', messages: [] }))
|
||||
.rejects.toThrow(/store DEEPSEEK_API_KEY with the credentials service, or export DEEPSEEK_API_KEY/)
|
||||
})
|
||||
|
||||
it('prefers explicit config over env for key and base URL', async () => {
|
||||
@@ -927,23 +850,32 @@ describe('plugin registration and config', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('adapter is constructible directly for embedding', async () => {
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
it('adapter is constructible directly for embedding over the shared resolver', async () => {
|
||||
const adapter = adapterOf()
|
||||
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
|
||||
await expect(adapter.listModels('deepseek')).resolves.toEqual([])
|
||||
// Direct embedding shares the plugin's one resolve step, so it advertises
|
||||
// the same default catalog instead of a divergent empty one.
|
||||
await expect(adapter.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
})
|
||||
|
||||
it('resolves connection facts and the credential exactly once per stream call', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url }))
|
||||
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
|
||||
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
|
||||
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
|
||||
expect(options).toHaveBeenCalledTimes(1)
|
||||
expect(resolveApiKey).toHaveBeenCalledTimes(1)
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer per-request-key')
|
||||
})
|
||||
|
||||
it('rejects invalid idle watchdog bounds for direct and plugin composition', async () => {
|
||||
expect(() => new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: Number.POSITIVE_INFINITY,
|
||||
})).toThrow(/streamIdleTimeoutMs.*positive finite/)
|
||||
expect(() => new DeepSeekAdapter({
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})).toThrow(/streamIdleTimeoutMs.*no greater/)
|
||||
expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: Number.POSITIVE_INFINITY }))
|
||||
.toThrow(/streamIdleTimeoutMs.*positive finite/)
|
||||
expect(() => resolveAdapterOptions({ streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.toThrow(/streamIdleTimeoutMs.*no greater/)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
163
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
Normal file
163
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
82
packages/llm/llm-deepseek/tests/mock-server.ts
Normal file
82
packages/llm/llm-deepseek/tests/mock-server.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
|
||||
/** One scripted behavior for the next request the mock server receives. */
|
||||
export type Behavior =
|
||||
| { kind: 'sse'; events: string[]; delayMs?: number }
|
||||
| { kind: 'http-error'; status: number; body: string; contentType?: string; headers?: Record<string, string> }
|
||||
| { kind: 'close-early'; events: string[] }
|
||||
|
||||
export interface MockServer {
|
||||
url: string
|
||||
/** Bodies of received requests, in order. */
|
||||
requests: unknown[]
|
||||
/** Header bags of received requests, in order (parallel to `requests`). */
|
||||
headers: IncomingMessage['headers'][]
|
||||
script: Behavior[]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
const servers: Server[] = []
|
||||
|
||||
/** Close every server opened since the last call; run from each spec's afterEach. */
|
||||
export async function closeMockServers(): Promise<void> {
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
}
|
||||
|
||||
/** A minimal complete text generation, reused by request-shape assertions. */
|
||||
export const textEvents = [
|
||||
'{"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
|
||||
'{"choices":[{"delta":{"content":"hello"}}]}',
|
||||
'{"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
|
||||
'[DONE]',
|
||||
]
|
||||
|
||||
/** Local chat-completions stand-in: replays scripted behaviors per request. */
|
||||
export async function mockServer(script: Behavior[]): Promise<MockServer> {
|
||||
const requests: unknown[] = []
|
||||
const headers: IncomingMessage['headers'][] = []
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
let body = ''
|
||||
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
||||
request.on('end', () => {
|
||||
requests.push(JSON.parse(body))
|
||||
headers.push(request.headers)
|
||||
const behavior = script.shift()
|
||||
if (!behavior) {
|
||||
response.writeHead(500).end('mock script exhausted')
|
||||
return
|
||||
}
|
||||
if (behavior.kind === 'http-error') {
|
||||
response.writeHead(behavior.status, {
|
||||
'content-type': behavior.contentType ?? 'application/json',
|
||||
...behavior.headers,
|
||||
})
|
||||
response.end(behavior.body)
|
||||
return
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
const write = (index: number): void => {
|
||||
if (index >= behavior.events.length) {
|
||||
if (behavior.kind === 'sse') response.end()
|
||||
else response.destroy() // close-early: drop the socket mid-stream
|
||||
return
|
||||
}
|
||||
response.write(`data: ${behavior.events[index]}\n\n`)
|
||||
setTimeout(() => { write(index + 1) }, behavior.kind === 'sse' ? behavior.delayMs ?? 0 : 5)
|
||||
}
|
||||
write(0)
|
||||
})
|
||||
})
|
||||
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}`,
|
||||
requests,
|
||||
headers,
|
||||
script,
|
||||
close: () => new Promise(resolve => server.close(() => { resolve() })),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user