loader-composition.spec.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. /**
  2. * Real-composition guard for the dynamic-configuration chain: LlmService,
  3. * settings-local, credentials-local, and llm-deepseek boot from a test-only
  4. * cordis.yml through the actual Loader + Include path, external edits of
  5. * settings.yaml and .env hot-publish through their providers, and the very
  6. * next request carries the fresh base URL and credential. The same adapter
  7. * composition without settings or credentials entries keeps entry-config
  8. * behavior — the documented optional-inject fallback.
  9. */
  10. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  11. import { tmpdir } from 'node:os'
  12. import { join } from 'node:path'
  13. import { pathToFileURL } from 'node:url'
  14. import { afterEach, describe, expect, it, vi } from 'vitest'
  15. import { Context } from 'cordis'
  16. import Loader from '@cordisjs/plugin-loader'
  17. import Include from '@cordisjs/plugin-include'
  18. import LlmService from '@deepseek-ai/dsh-llm'
  19. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  20. import CredentialsLocal from '@deepseek-ai/dsh-credentials-local'
  21. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  22. import SettingsLocal from '@deepseek-ai/dsh-settings-local'
  23. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  24. import { assemble } from './assemble.ts'
  25. import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
  26. const NS = settingsNamespace('llm-deepseek')
  27. const KEY_REF = credentialRef('DEEPSEEK_API_KEY')
  28. let root: string | undefined
  29. let context: Context | undefined
  30. afterEach(async () => {
  31. await context?.fiber.dispose()
  32. context = undefined
  33. if (root !== undefined) await rm(root, { recursive: true, force: true })
  34. root = undefined
  35. await closeMockServers()
  36. vi.unstubAllEnvs()
  37. })
  38. async function loadComposition(
  39. options: { withDynamic: boolean; baseURL: string; reuseRoot?: string },
  40. ): Promise<{ ctx: Context; settingsPath: string; envPath: string }> {
  41. // A reused root is the restart case: the same harness home, its documents
  42. // exactly as the previous process left them.
  43. const fresh = options.reuseRoot === undefined
  44. root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
  45. const settingsPath = join(root, 'settings.yaml')
  46. const envPath = join(root, '.env')
  47. if (options.withDynamic && fresh) {
  48. await writeFile(settingsPath, '# personal settings\n')
  49. await writeFile(envPath, 'DEEPSEEK_API_KEY=boot-key\n')
  50. }
  51. const configPath = join(root, 'cordis.yml')
  52. await writeFile(configPath, [
  53. '- id: llm',
  54. " name: 'test-llm-service'",
  55. ...options.withDynamic
  56. ? [
  57. '- id: settings',
  58. " name: '@deepseek-ai/dsh-settings-local'",
  59. ' config:',
  60. ` path: ${JSON.stringify(settingsPath)}`,
  61. ' debounceMs: 10',
  62. '- id: credentials',
  63. " name: '@deepseek-ai/dsh-credentials-local'",
  64. ' config:',
  65. ` path: ${JSON.stringify(envPath)}`,
  66. ' debounceMs: 10',
  67. ]
  68. : [],
  69. '- id: llm-deepseek',
  70. " name: '@deepseek-ai/dsh-llm-deepseek'",
  71. ' config:',
  72. ` baseURL: ${JSON.stringify(options.baseURL)}`,
  73. ...options.withDynamic ? [] : [' apiKey: entry-key'],
  74. '',
  75. ].join('\n'))
  76. const ctx = new Context()
  77. context = ctx
  78. ctx.baseUrl = pathToFileURL(root).href + '/'
  79. await ctx.plugin(Loader)
  80. ctx.loader.builtins.include = Include
  81. const modules = new Map<string, unknown>([
  82. ['test-llm-service', LlmService],
  83. ['@deepseek-ai/dsh-settings-local', SettingsLocal],
  84. ['@deepseek-ai/dsh-credentials-local', CredentialsLocal],
  85. ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek],
  86. ])
  87. ctx.loader.internal = {
  88. version: 'v2',
  89. async import(specifier: string) {
  90. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  91. return modules.get(specifier)
  92. },
  93. } as unknown as NonNullable<typeof ctx.loader.internal>
  94. await ctx.loader.create({
  95. name: 'cordis:include',
  96. config: { path: pathToFileURL(configPath).href },
  97. })
  98. await ctx.loader.await()
  99. return { ctx, settingsPath, envPath }
  100. }
  101. describe('llm-deepseek real dynamic composition', () => {
  102. it('boots from cordis.yml and routes the next request after external settings and .env edits', async () => {
  103. vi.stubEnv('DEEPSEEK_API_KEY', '')
  104. const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
  105. const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
  106. const { ctx, settingsPath, envPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
  107. expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
  108. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  109. expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key')
  110. // External edits, exactly as a user or the web UI would leave them on disk.
  111. await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`)
  112. await vi.waitFor(() => {
  113. expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
  114. }, { timeout: 5000 })
  115. await writeFile(envPath, 'DEEPSEEK_API_KEY=rotated-key\n')
  116. await vi.waitFor(async () => {
  117. expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
  118. }, { timeout: 5000 })
  119. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  120. expect(serverA.requests).toHaveLength(1)
  121. expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
  122. })
  123. it('keeps a stored key writable and rotatable across a real restart', async () => {
  124. // No ambient DEEPSEEK_API_KEY: the shipped surfaces no longer hoist
  125. // $DSH_HOME/.env into process.env, so a stored key must stay file-sourced.
  126. vi.stubEnv('DEEPSEEK_API_KEY', '')
  127. const first = await mockServer([{ kind: 'sse', events: textEvents }])
  128. const second = await mockServer([{ kind: 'sse', events: textEvents }])
  129. const boot = await loadComposition({ withDynamic: true, baseURL: first.url })
  130. const home = root!
  131. await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui')
  132. expect(await boot.ctx.get('credentials')!.describe(KEY_REF))
  133. .toEqual({ configured: true, source: 'file', writable: true })
  134. await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] })
  135. expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui')
  136. await boot.ctx.fiber.dispose()
  137. context = undefined
  138. // Restart over the same harness home.
  139. const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home })
  140. const credentials = restarted.ctx.get('credentials')!
  141. // The stored key is still the provider's own writable file entry — not a
  142. // read-only launch override, which is what hoisting it would have made it.
  143. expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' })
  144. expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
  145. // Rotation still works after the restart, and the next request uses it.
  146. await credentials.set(KEY_REF, 'rotated-after-restart')
  147. await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })
  148. expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
  149. })
  150. it('boots the same adapter without settings or credentials entries on entry config alone', async () => {
  151. vi.stubEnv('DEEPSEEK_API_KEY', '')
  152. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  153. const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })
  154. expect(ctx.get('settings')).toBeUndefined()
  155. expect(ctx.get('credentials')).toBeUndefined()
  156. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  157. expect(server.headers[0]?.authorization).toBe('Bearer entry-key')
  158. })
  159. })