loader-composition.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. /**
  2. * Real-composition guard for the dynamic-configuration chain: LlmRuntime,
  3. * settings-file, 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 the credentials document 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 { mkdir, 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 '@deepseek-ai/cordis'
  16. import Loader from '@deepseek-ai/cordis-plugin-loader'
  17. import Include from '@deepseek-ai/cordis-plugin-include'
  18. import LlmRuntime from '@deepseek-ai/dsh-llm'
  19. import AgentRegistry from '@deepseek-ai/dsh-agent'
  20. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  21. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  22. import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
  23. import { settingsNamespace } from '@deepseek-ai/dsh-settings'
  24. import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
  25. import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id'
  26. import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions'
  27. import * as SessionLogDeepSeek from '@deepseek-ai/dsh-session-log-deepseek'
  28. import * as DeepSeekPluginPackageInventory from '@deepseek-ai/dsh-plugin-package-inventory-deepseek'
  29. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  30. import { assemble } from './assemble.ts'
  31. import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
  32. const NS = settingsNamespace('llm-deepseek')
  33. const KEY_REF = credentialRef('DEEPSEEK_API_KEY')
  34. let root: string | undefined
  35. let context: Context | undefined
  36. afterEach(async () => {
  37. await context?.fiber.dispose()
  38. context = undefined
  39. if (root !== undefined) await rm(root, { recursive: true, force: true })
  40. root = undefined
  41. await closeMockServers()
  42. vi.unstubAllEnvs()
  43. })
  44. async function loadComposition(
  45. options: { withDynamic: boolean; baseURL: string; reuseRoot?: string; enableSessionLog?: boolean },
  46. ): Promise<{ ctx: Context; settingsPath: string; credentialsPath: string }> {
  47. // A reused root is the restart case: the same harness home, its documents
  48. // exactly as the previous process left them.
  49. const fresh = options.reuseRoot === undefined
  50. root = options.reuseRoot ?? await mkdtemp(join(tmpdir(), 'dsh-llm-composition-'))
  51. vi.stubEnv('DSH_HOME', root)
  52. const settingsPath = join(root, 'settings.yaml')
  53. const credentialsPath = join(root, '.credentials.yaml')
  54. if (options.withDynamic && fresh) {
  55. await writeFile(settingsPath, '# personal settings\n')
  56. await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
  57. }
  58. const configPath = join(root, 'cordis.yml')
  59. await writeFile(configPath, [
  60. '- id: llm',
  61. " name: '@deepseek-ai/dsh-llm'",
  62. '- id: session',
  63. " name: '@deepseek-ai/dsh-session'",
  64. '- id: agents',
  65. " name: '@deepseek-ai/dsh-agent'",
  66. '- id: deepseek-llm-api-extensions',
  67. " name: '@deepseek-ai/dsh-deepseek-llm-api-extensions'",
  68. '- id: session-log-deepseek',
  69. " name: '@deepseek-ai/dsh-session-log-deepseek'",
  70. ...options.enableSessionLog === true
  71. ? [' config:', ' enabled: true']
  72. : [],
  73. '- id: plugin-package-inventory-deepseek',
  74. " name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek'",
  75. ...options.withDynamic
  76. ? [
  77. '- id: settings',
  78. " name: '@deepseek-ai/dsh-settings-file'",
  79. ' config:',
  80. ` path: ${JSON.stringify(settingsPath)}`,
  81. ' debounceMs: 10',
  82. '- id: credentials',
  83. " name: '@deepseek-ai/dsh-credentials-local'",
  84. ' config:',
  85. ` path: ${JSON.stringify(credentialsPath)}`,
  86. ' debounceMs: 10',
  87. ]
  88. : [],
  89. '- id: llm-deepseek',
  90. " name: '@deepseek-ai/dsh-llm-deepseek'",
  91. ' config:',
  92. ` baseURL: ${JSON.stringify(options.baseURL)}`,
  93. '',
  94. ].join('\n'))
  95. const ctx = new Context()
  96. context = ctx
  97. ctx.baseUrl = pathToFileURL(root).href + '/'
  98. await ctx.plugin(Loader)
  99. ctx.loader.builtins.include = Include
  100. const modules = new Map<string, unknown>([
  101. ['@deepseek-ai/dsh-llm', LlmRuntime],
  102. ['@deepseek-ai/dsh-session', SessionStore],
  103. ['@deepseek-ai/dsh-agent', AgentRegistry],
  104. ['@deepseek-ai/dsh-deepseek-llm-api-extensions', DeepSeekLlmApiExtensionRegistry],
  105. ['@deepseek-ai/dsh-session-log-deepseek', SessionLogDeepSeek],
  106. ['@deepseek-ai/dsh-plugin-package-inventory-deepseek', DeepSeekPluginPackageInventory],
  107. ['@deepseek-ai/dsh-settings-file', FileSettingsProvider],
  108. ['@deepseek-ai/dsh-credentials-local', LocalCredentialProvider],
  109. ['@deepseek-ai/dsh-llm-deepseek', LlmDeepSeek],
  110. ])
  111. // The custom importer bypasses Node resolution; mirror the package manifests
  112. // a deployed cordis.yml has beside its declared dependencies.
  113. await Promise.all([...modules.keys()].map(async (packageName) => {
  114. const packageDir = join(root!, 'node_modules', ...packageName.split('/'))
  115. await mkdir(packageDir, { recursive: true })
  116. await writeFile(join(packageDir, 'package.json'), `${JSON.stringify({
  117. name: packageName,
  118. version: '0.1.0-rc.8',
  119. type: 'module',
  120. })}\n`)
  121. }))
  122. ctx.loader.internal = {
  123. version: 'v2',
  124. async import(specifier: string) {
  125. if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
  126. return modules.get(specifier)
  127. },
  128. } as unknown as NonNullable<typeof ctx.loader.internal>
  129. await ctx.loader.create({
  130. name: 'cordis:include',
  131. config: { path: pathToFileURL(configPath).href },
  132. })
  133. await ctx.loader.await()
  134. return { ctx, settingsPath, credentialsPath }
  135. }
  136. describe('llm-deepseek real dynamic composition', () => {
  137. it('keeps session upload off and package inventory on by default in the real Loader composition', async () => {
  138. vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
  139. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  140. const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })
  141. const session = ctx.sessions.create(SessionId('extension-composition'))
  142. session.append('turn/start', { turn: 1 })
  143. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], sessionId: session.id })
  144. const request = server.requests[0] as { dsh_plugin_packages: { version: number; packages: unknown[] } }
  145. expect(request).not.toHaveProperty('dsh_session_log')
  146. expect(request.dsh_plugin_packages.packages).toEqual(expect.arrayContaining([
  147. { name: '@deepseek-ai/dsh-deepseek-llm-api-extensions', version: '0.1.0-rc.8' },
  148. { name: '@deepseek-ai/dsh-llm-deepseek', version: '0.1.0-rc.8' },
  149. { name: '@deepseek-ai/dsh-session-log-deepseek', version: '0.1.0-rc.8' },
  150. ]))
  151. expect(request.dsh_plugin_packages.version).toBe(1)
  152. expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(-1)
  153. })
  154. it('sends the canonical session suffix when the Loader composition explicitly enables upload', async () => {
  155. vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
  156. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  157. const { ctx } = await loadComposition({
  158. withDynamic: false,
  159. baseURL: server.url,
  160. enableSessionLog: true,
  161. })
  162. const session = ctx.sessions.create(SessionId('extension-composition-enabled'))
  163. session.append('turn/start', { turn: 1 })
  164. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [], sessionId: session.id })
  165. const request = server.requests[0] as {
  166. dsh_session_log?: {
  167. version: number
  168. session: { id: string }
  169. afterSeq: number
  170. throughSeq: number
  171. events: Array<{ type: string; seq: number }>
  172. }
  173. }
  174. expect(request.dsh_session_log).toMatchObject({
  175. version: 1,
  176. session: { id: 'extension-composition-enabled' },
  177. afterSeq: -1,
  178. throughSeq: 0,
  179. events: [{ type: 'turn/start', seq: 0 }],
  180. })
  181. expect(SessionLogDeepSeek.acceptedThrough(session)).toBe(0)
  182. })
  183. it('boots from cordis.yml and routes the next request after external settings and credential edits', async () => {
  184. vi.stubEnv('DEEPSEEK_API_KEY', '')
  185. const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
  186. const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
  187. const { ctx, settingsPath, credentialsPath } = await loadComposition({ withDynamic: true, baseURL: serverA.url })
  188. expect(ctx.get('settings')!.describe().map(entry => entry.ns)).toEqual([NS])
  189. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  190. expect(serverA.headers[0]?.authorization).toBe('Bearer boot-key')
  191. expect(serverA.headers[0]?.['x-deepseek-harness-user-id']).toBe(getOrCreateAnonymousUserId())
  192. // External edits, exactly as a user or the web UI would leave them on disk.
  193. await writeFile(settingsPath, `llm-deepseek:\n baseURL: ${serverB.url}\n`)
  194. await vi.waitFor(() => {
  195. expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
  196. }, { timeout: 5000 })
  197. await writeFile(credentialsPath, 'version: 1\nrefs:\n DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
  198. await vi.waitFor(async () => {
  199. expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
  200. }, { timeout: 5000 })
  201. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  202. expect(serverA.requests).toHaveLength(1)
  203. expect(serverB.headers[0]?.authorization).toBe('Bearer rotated-key')
  204. })
  205. it('keeps a stored key writable and rotatable across a real restart', async () => {
  206. // No ambient DEEPSEEK_API_KEY: the shipped surfaces do not hoist
  207. // the credentials document into process.env, so a stored key must stay file-sourced.
  208. vi.stubEnv('DEEPSEEK_API_KEY', '')
  209. const first = await mockServer([{ kind: 'sse', events: textEvents }])
  210. const second = await mockServer([{ kind: 'sse', events: textEvents }])
  211. const boot = await loadComposition({ withDynamic: true, baseURL: first.url })
  212. const home = root!
  213. await boot.ctx.get('credentials')!.set(KEY_REF, 'stored-by-ui')
  214. expect(await boot.ctx.get('credentials')!.describe(KEY_REF))
  215. .toEqual({ configured: true, source: 'file', writable: true })
  216. await assemble(boot.ctx, { model: 'deepseek-v4-flash', messages: [] })
  217. expect(first.headers[0]?.authorization).toBe('Bearer stored-by-ui')
  218. await boot.ctx.fiber.dispose()
  219. context = undefined
  220. // Restart over the same harness home.
  221. const restarted = await loadComposition({ withDynamic: true, baseURL: second.url, reuseRoot: home })
  222. const credentials = restarted.ctx.get('credentials')!
  223. // The stored key is still the provider's own writable file entry — not a
  224. // read-only launch override, which is what hoisting it would have made it.
  225. expect(await credentials.resolve(KEY_REF)).toEqual({ value: 'stored-by-ui', source: 'file' })
  226. expect(await credentials.describe(KEY_REF)).toEqual({ configured: true, source: 'file', writable: true })
  227. // Rotation still works after the restart, and the next request uses it.
  228. await credentials.set(KEY_REF, 'rotated-after-restart')
  229. await assemble(restarted.ctx, { model: 'deepseek-v4-flash', messages: [] })
  230. expect(second.headers[0]?.authorization).toBe('Bearer rotated-after-restart')
  231. })
  232. it('boots the same adapter on entry config alone, resolving the reference from the environment', async () => {
  233. // No settings and no credentials provider: configuration carries only the
  234. // reference, so the environment is the whole credential plane here.
  235. vi.stubEnv('DEEPSEEK_API_KEY', 'entry-key')
  236. const server = await mockServer([{ kind: 'sse', events: textEvents }])
  237. const { ctx } = await loadComposition({ withDynamic: false, baseURL: server.url })
  238. expect(ctx.get('settings')).toBeUndefined()
  239. expect(ctx.get('credentials')).toBeUndefined()
  240. await assemble(ctx, { model: 'deepseek-v4-flash', messages: [] })
  241. expect(server.headers[0]?.authorization).toBe('Bearer entry-key')
  242. })
  243. })