index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /**
  2. * Shared route, framing, timeout, assembly, and validation policy for
  3. * model-backed session-title providers.
  4. * @module @deepseek-ai/dsh-session-title-llm
  5. */
  6. import type { Context } from '@deepseek-ai/cordis'
  7. import z from '@deepseek-ai/schemastery'
  8. import { createUserMessage, BlockAssembler } from '@deepseek-ai/dsh-llm'
  9. import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
  10. import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
  11. import { deepFreeze } from '@deepseek-ai/dsh-util-values'
  12. import type { SessionSeq } from '@deepseek-ai/dsh-session'
  13. import {
  14. normalizeSessionTitle,
  15. SessionTitleProviderId,
  16. } from '@deepseek-ai/dsh-session-title'
  17. import type {
  18. SessionTitleAutomaticMode,
  19. SessionTitleModelProvenance,
  20. SessionTitleProviderRequest,
  21. SessionTitleProviderResult,
  22. SessionTitleUserMessage,
  23. } from '@deepseek-ai/dsh-session-title'
  24. /** Exact model-visible request recorded before one auxiliary title dispatch. */
  25. export interface SessionTitleLlmRequestEventData {
  26. /** Registered title-provider identity responsible for the request. */
  27. readonly titleProvider: SessionTitleProviderId
  28. /** Exact human `user/message` seqs represented in `messages`. */
  29. readonly messageSeqs: SessionSeq[]
  30. /** Exact auxiliary LLM route. */
  31. readonly route: SessionTitleModelProvenance
  32. /** Exact auxiliary system prompt. */
  33. readonly system: string
  34. /** Exact auxiliary message list. */
  35. readonly messages: Message[]
  36. /** Exact auxiliary output-token cap. */
  37. readonly maxTokens: number
  38. }
  39. declare module '@deepseek-ai/dsh-session/types' {
  40. interface SessionEventMap {
  41. /** Log-only pre-dispatch record of one session-title model request. */
  42. 'session/title-llm-request': SessionTitleLlmRequestEventData
  43. }
  44. }
  45. /** Capability-owned timeout reason code for auxiliary title requests. */
  46. export const SESSION_TITLE_TIMEOUT_CODE = 'SESSION_TITLE_TIMEOUT'
  47. /** Required deployment policy for one model-backed title plugin. */
  48. export interface SessionTitleLlmConfig {
  49. /** Target word count for non-CJK titles. */
  50. readonly targetWords: number
  51. /** Target character count for Chinese, Japanese, or Korean titles. */
  52. readonly targetCjkCharacters: number
  53. /** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
  54. readonly maxInputBytes: number
  55. /** Auxiliary generation output-token cap. */
  56. readonly maxOutputTokens: number
  57. /** End-to-end auxiliary request deadline in milliseconds. */
  58. readonly timeoutMs: number
  59. /** Optional explicit provider route; must be paired with `model`. */
  60. readonly provider?: string
  61. /** Optional explicit model id; must be paired with `provider`. */
  62. readonly model?: string
  63. }
  64. /** Validated immutable model-provider policy. */
  65. export interface ResolvedSessionTitleLlmConfig extends SessionTitleLlmConfig {}
  66. /** Shared Loader field schemas with no library defaults. */
  67. export const SessionTitleLlmConfigFields = {
  68. targetWords: z.number().step(1).min(1).required(),
  69. targetCjkCharacters: z.number().step(1).min(1).required(),
  70. maxInputBytes: z.number().step(1).min(1).required(),
  71. maxOutputTokens: z.number().step(1).min(1).required(),
  72. timeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).required(),
  73. provider: z.string(),
  74. model: z.string(),
  75. }
  76. /** Shared Loader schema with no library defaults. */
  77. export const SessionTitleLlmConfigSchema: z<SessionTitleLlmConfig> = z.object(SessionTitleLlmConfigFields)
  78. /** Complete configuration key set for direct construction validation. */
  79. const CONFIG_KEYS: ReadonlySet<string> = new Set([
  80. 'targetWords',
  81. 'targetCjkCharacters',
  82. 'maxInputBytes',
  83. 'maxOutputTokens',
  84. 'timeoutMs',
  85. 'provider',
  86. 'model',
  87. ])
  88. /** Validate one positive integer limit. */
  89. function assertPositiveInteger(name: string, value: number): void {
  90. if (!Number.isInteger(value) || value <= 0) {
  91. throw new Error(`session-title-llm: ${name} must be a positive integer`)
  92. }
  93. }
  94. /**
  95. * Validate and detach required model-provider configuration.
  96. * @param config - untrusted plugin configuration.
  97. * @returns immutable policy with optional route absence preserved.
  98. */
  99. export function resolveSessionTitleLlmConfig(
  100. config: SessionTitleLlmConfig,
  101. ): ResolvedSessionTitleLlmConfig {
  102. const candidate: unknown = config
  103. if (candidate === null || typeof candidate !== 'object') {
  104. throw new Error('session-title-llm: configuration is required')
  105. }
  106. const value = candidate as SessionTitleLlmConfig
  107. for (const key of Object.keys(value)) {
  108. if (!CONFIG_KEYS.has(key)) throw new Error(`session-title-llm: unknown config key "${key}"`)
  109. }
  110. assertPositiveInteger('targetWords', value.targetWords)
  111. assertPositiveInteger('targetCjkCharacters', value.targetCjkCharacters)
  112. assertPositiveInteger('maxInputBytes', value.maxInputBytes)
  113. assertPositiveInteger('maxOutputTokens', value.maxOutputTokens)
  114. assertPositiveInteger('timeoutMs', value.timeoutMs)
  115. if (value.timeoutMs > MAX_TIMER_DELAY_MS) {
  116. throw new Error(`session-title-llm: timeoutMs must not exceed ${MAX_TIMER_DELAY_MS}`)
  117. }
  118. const hasProvider = value.provider !== undefined
  119. const hasModel = value.model !== undefined
  120. if (hasProvider !== hasModel) {
  121. throw new Error('session-title-llm: provider and model must be supplied together')
  122. }
  123. if (hasProvider
  124. && (typeof value.provider !== 'string' || value.provider.length === 0
  125. || typeof value.model !== 'string' || value.model.length === 0)) {
  126. throw new Error('session-title-llm: provider and model overrides must be non-empty strings')
  127. }
  128. return deepFreeze({ ...value })
  129. }
  130. /** Select the provider-owned message subset from one fixed service revision. */
  131. export type SessionTitleLlmMessageSelector = (
  132. messages: readonly SessionTitleUserMessage[],
  133. ) => readonly SessionTitleUserMessage[]
  134. /**
  135. * Register one model-backed provider through the shared configuration and call policy.
  136. * @param ctx - context exposing the title and LLM services.
  137. * @param config - untrusted required deployment policy.
  138. * @param id - stable plugin id recorded with generated titles.
  139. * @param automatic - provider-owned automatic generation cadence.
  140. * @param selectMessages - exact source-message selection for one revision.
  141. */
  142. export function registerSessionTitleLlmProvider(
  143. ctx: Context,
  144. config: SessionTitleLlmConfig,
  145. id: string,
  146. automatic: SessionTitleAutomaticMode,
  147. selectMessages: SessionTitleLlmMessageSelector,
  148. ): void {
  149. const resolved = resolveSessionTitleLlmConfig(config)
  150. const titleProvider = SessionTitleProviderId(id)
  151. ctx.sessionTitle.register({
  152. id: titleProvider,
  153. automatic,
  154. async generate(request) {
  155. return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages), titleProvider)
  156. },
  157. })
  158. }
  159. /** Resolve the explicit pair or the exact route captured from `request/header`. */
  160. function resolveRoute(
  161. config: ResolvedSessionTitleLlmConfig,
  162. request: SessionTitleProviderRequest,
  163. ): SessionTitleModelProvenance {
  164. if (config.provider !== undefined && config.model !== undefined) {
  165. return { provider: config.provider, model: config.model }
  166. }
  167. if (request.route === undefined) {
  168. throw new Error('session-title-llm: no logged request route is available; configure provider and model together')
  169. }
  170. return request.route
  171. }
  172. /** Stable language-aware system instruction shared by both provider plugins. */
  173. function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
  174. return [
  175. 'Create a concise title for an AI coding-assistant session from the supplied human messages.',
  176. 'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.',
  177. 'Use the language of the messages.',
  178. `Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
  179. ].join('\n')
  180. }
  181. /** Frame exact messages as JSON so user text cannot break structural delimiters. */
  182. function frameMessages(messages: readonly SessionTitleUserMessage[]): string {
  183. return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
  184. }
  185. /** Translate terminal finish reasons into an auxiliary-call failure. */
  186. function finishError(finish: FinishReason): Error | undefined {
  187. switch (finish.kind) {
  188. case 'stop':
  189. return undefined
  190. case 'error':
  191. case 'aborted': {
  192. const error = new Error(finish.failure.message) as Error & { code?: string }
  193. error.code = finish.failure.code
  194. return error
  195. }
  196. case 'max-tokens':
  197. return new Error('session-title-llm: title output reached maxOutputTokens')
  198. case 'tool-calls':
  199. return new Error('session-title-llm: title model unexpectedly requested a tool')
  200. default:
  201. return new Error(`session-title-llm: unsupported finish reason "${String((finish as { kind?: unknown }).kind)}"`)
  202. }
  203. }
  204. /**
  205. * Generate one title through the shared auxiliary LLM call.
  206. * @param ctx - context exposing the registered LLM service.
  207. * @param config - validated model-provider policy.
  208. * @param request - service-owned session, route, message snapshot, and cancellation.
  209. * @param selectedMessages - exact provider-selected subset to frame and attribute.
  210. * @param titleProvider - registered title-provider identity recorded with the request.
  211. * @returns normalized non-empty title, exact source seqs, and used model route.
  212. */
  213. export async function generateSessionTitleWithLlm(
  214. ctx: Context,
  215. config: ResolvedSessionTitleLlmConfig,
  216. request: SessionTitleProviderRequest,
  217. selectedMessages: readonly SessionTitleUserMessage[],
  218. titleProvider: SessionTitleProviderId,
  219. ): Promise<SessionTitleProviderResult> {
  220. request.signal.throwIfAborted()
  221. if (selectedMessages.length === 0) {
  222. throw new Error('session-title-llm: at least one source message is required')
  223. }
  224. const framedInput = frameMessages(selectedMessages)
  225. const inputBytes = Buffer.byteLength(framedInput, 'utf8')
  226. if (inputBytes > config.maxInputBytes) {
  227. throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
  228. }
  229. const route = resolveRoute(config, request)
  230. const messages: Message[] = [createUserMessage({
  231. content: [{ type: 'text', text: framedInput }],
  232. source: { kind: 'plugin', plugin: 'dsh-session-title-llm' },
  233. })]
  234. const system = systemPrompt(config)
  235. using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
  236. const options: GenerateOptions = deepFreeze({
  237. provider: route.provider,
  238. model: route.model,
  239. messages,
  240. system,
  241. maxTokens: config.maxOutputTokens,
  242. sessionId: request.session.id,
  243. purpose: 'session-title',
  244. signal: callDeadline.signal,
  245. })
  246. request.session.append('session/title-llm-request', {
  247. titleProvider,
  248. messageSeqs: selectedMessages.map(message => message.seq),
  249. route,
  250. system,
  251. messages,
  252. maxTokens: config.maxOutputTokens,
  253. })
  254. callDeadline.signal.throwIfAborted()
  255. const assembler = new BlockAssembler()
  256. for await (const chunk of ctx.llm.stream(options)) {
  257. callDeadline.signal.throwIfAborted()
  258. assembler.push(chunk)
  259. }
  260. callDeadline.signal.throwIfAborted()
  261. const terminalError = finishError(assembler.finish)
  262. if (terminalError !== undefined) throw terminalError
  263. const blocks = assembler.blocks()
  264. if (blocks.some(block => block.type === 'tool-call')) {
  265. throw new Error('session-title-llm: title output must contain text only')
  266. }
  267. const text = blocks
  268. .filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text')
  269. .map(block => block.text)
  270. .join(' ')
  271. const title = normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER)
  272. if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
  273. return {
  274. title,
  275. messageSeqs: selectedMessages.map(message => message.seq),
  276. model: route,
  277. }
  278. }