Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
This commit is contained in:
@@ -62,14 +62,16 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
|
||||
if (key === undefined) throw new Error('e2e ran without DEEPSEEK_API_KEY')
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-e2e-credentials-'))
|
||||
try {
|
||||
await writeFile(join(dir, '.env'), `DEEPSEEK_API_KEY=${key}\n`, { mode: 0o600 })
|
||||
// JSON.stringify quotes the value: YAML is a JSON superset, so a real
|
||||
// key survives whatever characters it happens to carry.
|
||||
await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
|
||||
// Scrub the ambient variable so only the credential seam can supply the
|
||||
// key: this request proves the per-request resolution path end to end.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false })
|
||||
await ctx.plugin(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
|
||||
const result = await assemble(ctx, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment'
|
||||
import LlmService, { createUserMessage,
|
||||
CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
ProviderRequestId,
|
||||
@@ -23,9 +24,12 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
async function harness(baseURL: string, config: object = {}) {
|
||||
// Configuration carries only the reference; the key comes from the
|
||||
// environment, which is the whole credential plane without a mounted seam.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'test-key', baseURL, ...config })
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL, ...config })
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -565,7 +569,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const fiber = await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
})
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
@@ -584,7 +587,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: {
|
||||
mode: 'always',
|
||||
@@ -603,7 +605,7 @@ describe('plugin registration and config', () => {
|
||||
it('owns the deepseek provider and advertises the default models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
@@ -631,7 +633,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
reasoningEffort: effort,
|
||||
})
|
||||
@@ -652,7 +653,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
thinking: 'disabled',
|
||||
reasoningEffort: 'off',
|
||||
@@ -672,7 +672,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
thinking: 'disabled',
|
||||
reasoningEffort,
|
||||
@@ -699,17 +698,10 @@ describe('plugin registration and config', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes a literal API key and treats whitespace as absent', () => {
|
||||
expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key')
|
||||
const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' })
|
||||
expect(whitespace.apiKey).toBeUndefined()
|
||||
expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY')
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] },
|
||||
{ provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] },
|
||||
@@ -720,7 +712,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [
|
||||
{ id: 'private-fast', contextWindow: 32_000 },
|
||||
@@ -754,7 +745,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaultContextWindow: 256_000,
|
||||
models: [
|
||||
@@ -775,7 +765,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [],
|
||||
})
|
||||
@@ -792,7 +781,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [...models],
|
||||
})).rejects.toThrow(message)
|
||||
@@ -824,7 +812,6 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
expect(() => {
|
||||
LlmDeepSeek.apply(ctx, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [{ id: 'invalid-context', contextWindow: 0 }],
|
||||
})
|
||||
@@ -841,7 +828,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
defaultContextWindow,
|
||||
})).rejects.toThrow(/defaultContextWindow/)
|
||||
@@ -858,7 +844,6 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
maxTokens,
|
||||
})).rejects.toThrow(/maxTokens/)
|
||||
@@ -886,13 +871,14 @@ describe('plugin registration and config', () => {
|
||||
await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2)
|
||||
const first = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(first.finish).toMatchObject({ kind: 'error', failure: { code: 'MISSING_CREDENTIAL' } })
|
||||
// The guidance leads with the credential store — the path that keeps the
|
||||
// secret out of configuration files — and mentions a literal key last.
|
||||
// The guidance leads with the managed credential store.
|
||||
const second = await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(second.finish.kind).toBe('error')
|
||||
if (second.finish.kind !== 'error') throw new Error('expected an error finish')
|
||||
// The guidance names both places a credential can come from, and nothing
|
||||
// else: configuration carries the reference, never a literal key.
|
||||
expect(second.finish.failure.message)
|
||||
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*as a last resort.*"apiKey"/s)
|
||||
.toMatch(/store DEEPSEEK_API_KEY through the credentials service.*export DEEPSEEK_API_KEY/s)
|
||||
})
|
||||
|
||||
it('reads the ambient variable when no credentials seam is mounted', async () => {
|
||||
@@ -928,13 +914,33 @@ describe('plugin registration and config', () => {
|
||||
it('uses DEEPSEEK_BASE_URL when config omits baseURL', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', server.url)
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k' })
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(server.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
|
||||
it('takes DEEPSEEK_BASE_URL from any environment layer, with explicit config still on top', () => {
|
||||
const trusted = createEnvironmentSnapshot([
|
||||
{ source: 'user-env', path: '/home/.dsh/.env', values: { DEEPSEEK_BASE_URL: 'https://user.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({}, trusted).baseURL).toBe('https://user.example')
|
||||
// The product trusts the project it is launched in, so a checkout can
|
||||
// point its own agent at the gateway that checkout is meant to use.
|
||||
const project = createEnvironmentSnapshot([
|
||||
{ source: 'project-env', path: '/work/.env', values: { DEEPSEEK_BASE_URL: 'https://project.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({}, project).baseURL).toBe('https://project.example')
|
||||
// An explicitly configured endpoint outranks every environment layer, so a
|
||||
// stale shell value cannot rewrite a deployment's own gateway.
|
||||
const shell = createEnvironmentSnapshot([
|
||||
{ source: 'process', values: { DEEPSEEK_BASE_URL: 'https://stale.example' } },
|
||||
])
|
||||
expect(resolveAdapterOptions({ baseURL: 'https://gateway.internal' }, shell).baseURL).toBe('https://gateway.internal')
|
||||
})
|
||||
it('defaults to the public base URL without config or env', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'k')
|
||||
vi.stubEnv('DEEPSEEK_BASE_URL', undefined)
|
||||
@@ -975,12 +981,10 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: 0,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
streamIdleTimeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})).rejects.toThrow(/streamIdleTimeoutMs/)
|
||||
@@ -991,45 +995,9 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
retryPolicy: { mode: 'normal', maxRetries: -1 },
|
||||
})).rejects.toThrow(/retryPolicy/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('API key format', () => {
|
||||
it('trims a padded literal apiKey', () => {
|
||||
expect(resolveAdapterOptions({ apiKey: ' sk-abc ' }).apiKey).toBe('sk-abc')
|
||||
})
|
||||
|
||||
it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => {
|
||||
expect(resolveAdapterOptions({}).apiKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('treats a whitespace-only literal apiKey as absent, not as a failure', () => {
|
||||
// This adapter's absence has a defined fallback, so a blank literal
|
||||
// resolves through apiKeyEnv like an omitted one. (llm-pi-ai refuses a
|
||||
// blank one instead: there, absence selects provider-native or OAuth
|
||||
// authentication rather than a different source for the same key.)
|
||||
const resolved = resolveAdapterOptions({ apiKey: ' ', apiKeyEnv: 'CUSTOM_API_KEY' })
|
||||
expect(resolved.apiKey).toBeUndefined()
|
||||
expect(resolved.apiKeyEnv).toBe('CUSTOM_API_KEY')
|
||||
})
|
||||
|
||||
it('rejects a literal apiKey no header can carry', () => {
|
||||
expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' }))
|
||||
.toThrow(/no HTTP header can carry/)
|
||||
})
|
||||
|
||||
it('never echoes the key in the rejection', () => {
|
||||
const secret = 'sk-\u{1F600}supersecret'
|
||||
expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow()
|
||||
try {
|
||||
resolveAdapterOptions({ apiKey: secret })
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('supersecret')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,7 +48,7 @@ async function boot(dir: string, config: object): Promise<Harness> {
|
||||
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(CredentialsLocal, { path: join(dir, '.credentials.yaml'), watch: false })
|
||||
await ctx.plugin(LlmDeepSeek, config)
|
||||
return { ctx, settingsFiber }
|
||||
}
|
||||
@@ -61,7 +61,7 @@ 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')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
|
||||
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await boot(dir, { baseURL: serverA.url })
|
||||
@@ -78,18 +78,6 @@ describe('request-level dynamic configuration', () => {
|
||||
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()
|
||||
@@ -124,7 +112,7 @@ describe('request-level dynamic configuration', () => {
|
||||
|
||||
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' })
|
||||
const { ctx } = await boot(dir, { 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' }] })
|
||||
@@ -135,7 +123,7 @@ describe('request-level dynamic configuration', () => {
|
||||
|
||||
it('re-registers the route in place when the captured retry policy changes, without an empty-registry window', async () => {
|
||||
const dir = await home()
|
||||
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
|
||||
|
||||
// Observing the topology event, not just the end state: disposing and
|
||||
// re-registering also lands on the right final registry, but publishes an
|
||||
@@ -160,7 +148,7 @@ describe('request-level dynamic configuration', () => {
|
||||
|
||||
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' })
|
||||
const { ctx } = await boot(dir, { 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.
|
||||
@@ -172,17 +160,16 @@ describe('request-level dynamic configuration', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
it('keeps the whole last-good snapshot when a rejected one changed the URL', async () => {
|
||||
const dir = await home()
|
||||
const good = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'good-key')
|
||||
const { ctx } = await boot(dir, { baseURL: good.url })
|
||||
|
||||
// One snapshot moves the endpoint AND the literal key, and fails the
|
||||
// resolve step beyond the schema (duplicate catalog ids).
|
||||
// One snapshot moves the endpoint and fails the resolve step beyond the
|
||||
// schema (duplicate catalog ids).
|
||||
await ctx.settings.update(NS, {
|
||||
apiKey: 'rejected-key',
|
||||
baseURL: rejected.url,
|
||||
models: [{ id: 'dup' }, { id: 'dup' }],
|
||||
})
|
||||
@@ -198,7 +185,7 @@ describe('request-level dynamic configuration', () => {
|
||||
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')
|
||||
await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
|
||||
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 })
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Real-composition guard for the dynamic-configuration chain: LlmService,
|
||||
* settings-local, credentials-local, and llm-deepseek boot from a test-only
|
||||
* cordis.yml through the actual Loader + Include path, external edits of
|
||||
* settings.yaml and .env hot-publish through their providers, and the very
|
||||
* settings.yaml and the credentials document hot-publish through their providers, and the very
|
||||
* next request carries the fresh base URL and credential. The same adapter
|
||||
* composition without settings or credentials entries keeps entry-config
|
||||
* behavior — the documented optional-inject fallback.
|
||||
@@ -42,16 +42,16 @@ afterEach(async () => {
|
||||
|
||||
async function loadComposition(
|
||||
options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
|
||||
): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
|
||||
): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> {
|
||||
// A reused root is the restart case: the same harness home, its documents
|
||||
// exactly as the previous process left them.
|
||||
const fresh = options.reuseRoot === undefined
|
||||
root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
|
||||
const settingsPath = join(root, 'settings.yaml')
|
||||
const envPath = join(root, '.env')
|
||||
const credentialsPath = join(root, '.credentials.yaml')
|
||||
if (options.withDynamic && fresh) {
|
||||
await writeFile(settingsPath, '# personal settings\n')
|
||||
await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
|
||||
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
|
||||
}
|
||||
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
@@ -68,7 +68,7 @@ async function loadComposition(
|
||||
'- id: credentials',
|
||||
" name: '@deepseek-ai/dsh-credentials-local'",
|
||||
' config:',
|
||||
` path: ${JSON.stringify(envPath)}`,
|
||||
` path: ${JSON.stringify(credentialsPath)}`,
|
||||
' debounceMs: 10',
|
||||
]
|
||||
: [],
|
||||
@@ -76,7 +76,6 @@ async function loadComposition(
|
||||
" name: '@deepseek-ai/dsh-llm-deepseek'",
|
||||
' config:',
|
||||
` baseURL: ${JSON.stringify(options.baseURL)}`,
|
||||
...options.withDynamic ? [] : [' apiKey: entry-key'],
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
@@ -103,15 +102,15 @@ async function loadComposition(
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
return { ctx, settingsPath, envPath }
|
||||
return { ctx, settingsPath, credentialsPath }
|
||||
}
|
||||
|
||||
describe('llm-deepseek real dynamic composition', () => {
|
||||
it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => {
|
||||
it('boots from cordis.yml and routes the next request after external settings and credential edits', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
|
||||
const { ctx, settingsPath, credentialsPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
|
||||
|
||||
expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
|
||||
await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
|
||||
@@ -122,7 +121,7 @@ describe('llm-deepseek real dynamic composition', () => {
|
||||
await vi.waitFor(() => {
|
||||
expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
|
||||
}, { timeout: 5000 })
|
||||
await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n')
|
||||
await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
|
||||
await vi.waitFor(async () => {
|
||||
expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
|
||||
}, { timeout: 5000 })
|
||||
@@ -134,7 +133,7 @@ describe('llm-deepseek real dynamic composition', () => {
|
||||
|
||||
it('keeps a stored key writable and rotatable across a real restart', async () => {
|
||||
// No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
|
||||
// $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
|
||||
// the credentials document into process.env, so a stored key must stay file-sourced.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const first = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const second = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
@@ -161,8 +160,10 @@ describe('llm-deepseek real dynamic composition', () => {
|
||||
expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
|
||||
})
|
||||
|
||||
it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => {
|
||||
// No settings and no credentials provider: configuration carries only the
|
||||
// reference, so the environment is the whole credential plane here.
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user