index.ts 12 KB

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