loader-composition.spec.ts 13 KB

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