mock-adapter.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk, SystemPromptUpdate } from '@deepseek-ai/dsh-llm'
  2. import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
  3. /** Helpers to write scripted responses tersely. */
  4. export function textResponse(text: string): StreamChunk[] {
  5. return [
  6. { type: 'block-start', index: 0, blockType: 'text' },
  7. ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
  8. { type: 'block-end', index: 0, block: { type: 'text', text } },
  9. { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
  10. { type: 'finish', reason: { kind: 'stop' } },
  11. ]
  12. }
  13. /**
  14. * Like {@link textResponse} but the stream ends with a `max-tokens` finish —
  15. * the model was cut off at the output-token ceiling (DeepSeek's `length`).
  16. * Used to exercise the turn-end `max-tokens` surfacing rule.
  17. */
  18. export function maxTokensResponse(text: string): StreamChunk[] {
  19. return [
  20. { type: 'block-start', index: 0, blockType: 'text' },
  21. ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })),
  22. { type: 'block-end', index: 0, block: { type: 'text', text } },
  23. { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
  24. { type: 'finish', reason: { kind: 'max-tokens' } },
  25. ]
  26. }
  27. export function toolCallResponse(rawCallId: string, name: string, args: object, text?: string): StreamChunk[] {
  28. const callId = ToolCallId(rawCallId)
  29. const argumentsJson = JSON.stringify(args)
  30. const chunks: StreamChunk[] = []
  31. let index = 0
  32. if (text) {
  33. chunks.push(
  34. { type: 'block-start', index, blockType: 'text' },
  35. { type: 'text-delta', index, text },
  36. { type: 'block-end', index, block: { type: 'text', text } },
  37. )
  38. index += 1
  39. }
  40. chunks.push(
  41. { type: 'block-start', index, blockType: 'tool-call' },
  42. { type: 'tool-call-delta', index, id: callId, name, argumentsDelta: argumentsJson.slice(0, 5) },
  43. { type: 'tool-call-delta', index, id: callId, argumentsDelta: argumentsJson.slice(5) },
  44. {
  45. type: 'block-end',
  46. index,
  47. block: { type: 'tool-call', id: callId, name, arguments: argumentsJson },
  48. },
  49. { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
  50. { type: 'finish', reason: { kind: 'tool-calls' } },
  51. )
  52. return chunks
  53. }
  54. /** Script entry that streams the given chunks, then hangs until aborted. */
  55. export interface HangAfter {
  56. hangAfter: StreamChunk[]
  57. }
  58. /**
  59. * Mock adapter driven by a script: each model call consumes the next entry.
  60. * Records every request it receives for assertions. An entry may be a
  61. * function to compute chunks from the request, a 'hang' marker that
  62. * streams one chunk then waits until aborted, 'hang-slow' which takes
  63. * 50ms to notice the abort — a stand-in for slow real-world teardown
  64. * (LLM stream cancellation, tool unwinding) — or a {@link HangAfter}
  65. * scripting the exact chunks delivered before the hang.
  66. */
  67. export class MockAdapter extends LlmAdapter {
  68. requests: GenerateOptions[] = []
  69. /** Declared system prompt update mode of every route this adapter serves. */
  70. systemPromptUpdate?: SystemPromptUpdate
  71. constructor(
  72. private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow' | HangAfter)[],
  73. private readonly reasoning?: LlmModelReasoningInfo,
  74. private readonly defaultMaxTokens?: number,
  75. ) {
  76. super()
  77. }
  78. override resolveModel(
  79. provider: string,
  80. model: string,
  81. ): Promise<LlmResolvedModelInfo> {
  82. return Promise.resolve({
  83. provider,
  84. id: model,
  85. name: model,
  86. ...this.reasoning === undefined ? {} : { reasoning: this.reasoning },
  87. ...this.defaultMaxTokens === undefined ? {} : { defaultMaxTokens: this.defaultMaxTokens },
  88. ...this.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: this.systemPromptUpdate },
  89. })
  90. }
  91. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  92. this.requests.push(options)
  93. const entry = this.script.shift()
  94. if (!entry) throw new Error('MockAdapter: script exhausted')
  95. if (entry === 'hang') {
  96. yield { type: 'block-start', index: 0, blockType: 'text' }
  97. yield { type: 'text-delta', index: 0, text: 'partial' }
  98. await new Promise<void>((_resolve, reject) => {
  99. if (options.signal?.aborted) { reject(new Error('aborted')); return }
  100. options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  101. })
  102. return
  103. }
  104. if (typeof entry === 'object' && !Array.isArray(entry) && 'hangAfter' in entry) {
  105. for (const chunk of entry.hangAfter) yield chunk
  106. await new Promise<void>((_resolve, reject) => {
  107. if (options.signal?.aborted) { reject(new Error('aborted')); return }
  108. options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  109. })
  110. return
  111. }
  112. if (entry === 'hang-slow') {
  113. yield { type: 'block-start', index: 0, blockType: 'text' }
  114. yield { type: 'text-delta', index: 0, text: 'partial' }
  115. await new Promise<void>((_resolve, reject) => {
  116. const fail = (): void => { reject(new Error('aborted')) }
  117. if (options.signal?.aborted) { setTimeout(fail, 50); return }
  118. options.signal?.addEventListener('abort', () => { setTimeout(fail, 50) }, { once: true })
  119. })
  120. return
  121. }
  122. const chunks = typeof entry === 'function' ? entry(options) : entry
  123. for (const chunk of chunks) {
  124. if (options.signal?.aborted) throw new Error('aborted')
  125. yield chunk
  126. }
  127. }
  128. }