loader-composition.spec.ts 12 KB

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