import { AiModelStreamService } from './ai-model-stream.service'; import type { AiRuntimeConfig } from '../ai-config/dto/ai-config.dto'; const config: AiRuntimeConfig = { provider: 'DEEPSEEK' as AiRuntimeConfig['provider'], baseUrl: 'https://example.test/v1', apiKey: 'secret', defaultModel: 'deepseek-reasoner', timeoutMs: 1000, enabled: true, }; describe('AiModelStreamService', () => { afterEach(() => jest.restoreAllMocks()); it('分离思考、正文并拼接分片工具调用,且处理无尾随空行的最后事件', async () => { const chunks = [ 'data: {"choices":[{"delta":{"reasoning_content":"思考"}}]}\n\n', 'data: {"choices":[{"delta":{"content":"答案","tool_calls":[{"index":0,"id":"call_","function":{"name":"search_","arguments":"{\\"q\\":"}}]}}]}\n\n', 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"1","function":{"name":"students","arguments":"\\"张三\\"}"}}]}}]}', ]; async function* body() { for (const chunk of chunks) yield Buffer.from(chunk); } const service = new AiModelStreamService(); jest.spyOn(service as never, 'pinnedPost' as never).mockResolvedValue({ status: 200, contentType: 'text/event-stream', body: body(), } as never); const events = []; for await (const event of service.stream( config, [{ role: 'user', content: '查询' }], [], new AbortController().signal, )) events.push(event); expect(events).toEqual([ { type: 'reasoning', delta: '思考' }, { type: 'content', delta: '答案' }, { type: 'complete', toolCalls: [{ id: 'call_1', name: 'search_students', arguments: '{"q":"张三"}' }], }, ]); }); it('不向调用方暴露上游非 JSON 错误正文', async () => { async function* body() { yield Buffer.from('proxy internal detail'); } const service = new AiModelStreamService(); jest.spyOn(service as never, 'pinnedPost' as never).mockResolvedValue({ status: 502, contentType: 'text/plain', body: body(), } as never); const consume = async () => { for await (const _ of service.stream( config, [{ role: 'user', content: '查询' }], [], new AbortController().signal, )) void _; }; await expect(consume()).rejects.toThrow('AI 服务暂时不可用'); }); });