import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' /** Helpers to write scripted responses tersely. */ export function textResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } }, { type: 'finish', reason: { kind: 'stop' } }, ] } /** * Like {@link textResponse} but the stream ends with a `max-tokens` finish — * the model was cut off at the output-token ceiling (DeepSeek's `length`). * Used to exercise the turn-end `max-tokens` surfacing rule. */ export function maxTokensResponse(text: string): StreamChunk[] { return [ { type: 'block-start', index: 0, blockType: 'text' }, ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), { type: 'block-end', index: 0, block: { type: 'text', text } }, { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } }, { type: 'finish', reason: { kind: 'max-tokens' } }, ] } export function toolCallResponse(rawCallId: string, name: string, args: object, text?: string): StreamChunk[] { const callId = CallId(rawCallId) const argumentsJson = JSON.stringify(args) const chunks: StreamChunk[] = [] let index = 0 if (text) { chunks.push( { type: 'block-start', index, blockType: 'text' }, { type: 'text-delta', index, text }, { type: 'block-end', index, block: { type: 'text', text } }, ) index += 1 } chunks.push( { type: 'block-start', index, blockType: 'tool-call' }, { type: 'tool-call-delta', index, id: callId, name, argumentsDelta: argumentsJson.slice(0, 5) }, { type: 'tool-call-delta', index, id: callId, argumentsDelta: argumentsJson.slice(5) }, { type: 'block-end', index, block: { type: 'tool-call', id: callId, name, arguments: argumentsJson }, }, { type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ) return chunks } /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a * function to compute chunks from the request, or a 'hang' marker that * streams one chunk then waits until aborted. */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], private readonly reasoning?: LlmModelReasoningInfo, ) { super() } override resolveModel( provider: string, model: string, ): Promise { return Promise.resolve({ provider, id: model, name: model, ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, }) } async * stream(options: GenerateOptions): AsyncIterable { this.requests.push(options) const entry = this.script.shift() if (!entry) throw new Error('MockAdapter: script exhausted') if (entry === 'hang') { yield { type: 'block-start', index: 0, blockType: 'text' } yield { type: 'text-delta', index: 0, text: 'partial' } await new Promise((_resolve, reject) => { if (options.signal?.aborted) { reject(new Error('aborted')); return } options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) }) return } const chunks = typeof entry === 'function' ? entry(options) : entry for (const chunk of chunks) { if (options.signal?.aborted) throw new Error('aborted') yield chunk } } }