UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

334 lines (302 loc) 10.4 kB
import { describe, expect, it, vi } from 'vitest'; import * as uuidModule from '@/utils/uuid'; import { VertexAIStream } from './vertex-ai'; describe('VertexAIStream', () => { it('should transform Vertex AI stream to protocol stream', async () => { vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); const rawChunks = [ { candidates: [ { content: { role: 'model', parts: [{ text: '你好' }] }, safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE', probabilityScore: 0.06298828, severity: 'HARM_SEVERY_NEGLIGIBLE', severityScore: 0.10986328, }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE', probabilityScore: 0.05029297, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.078125, }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE', probabilityScore: 0.19433594, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.16015625, }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE', probabilityScore: 0.059326172, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.064453125, }, ], index: 0, }, ], usageMetadata: {}, modelVersion: 'gemini-1.5-flash-001', }, { candidates: [ { content: { role: 'model', parts: [{ text: '! 😊' }] }, safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE', probabilityScore: 0.052734375, severity: 'HARM_SEVRITY_NEGLIGIBLE', severityScore: 0.08642578, }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE', probabilityScore: 0.071777344, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.095214844, }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE', probabilityScore: 0.1640625, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.10498047, }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE', probabilityScore: 0.075683594, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.053466797, }, ], index: 0, }, ], modelVersion: 'gemini-1.5-flash-001', }, ]; const mockGoogleStream = new ReadableStream({ start(controller) { rawChunks.forEach((chunk) => controller.enqueue(chunk)); controller.close(); }, }); const onStartMock = vi.fn(); const onTextMock = vi.fn(); const onToolCallMock = vi.fn(); const onCompletionMock = vi.fn(); const protocolStream = VertexAIStream(mockGoogleStream, { callbacks: { onStart: onStartMock, onText: onTextMock, onToolsCalling: onToolCallMock, onCompletion: onCompletionMock, }, }); const decoder = new TextDecoder(); const chunks = []; // @ts-ignore for await (const chunk of protocolStream) { chunks.push(decoder.decode(chunk, { stream: true })); } expect(chunks).toEqual([ // text 'id: chat_1\n', 'event: text\n', `data: "你好"\n\n`, // text 'id: chat_1\n', 'event: text\n', `data: "! 😊"\n\n`, ]); expect(onStartMock).toHaveBeenCalledTimes(1); expect(onTextMock).toHaveBeenCalledTimes(2); expect(onCompletionMock).toHaveBeenCalledTimes(1); }); it('tool_calls', async () => { vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); const rawChunks = [ { candidates: [ { content: { role: 'model', parts: [ { functionCall: { name: 'realtime-weather____fetchCurrentWeather', args: { city: '杭州' }, }, }, ], }, finishReason: 'STOP', safetyRatings: [ { category: 'HARM_CATERY_HATE_SPEECH', probability: 'NEGLIGIBLE', probabilityScore: 0.09814453, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.07470703, }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE', probabilityScore: 0.1484375, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.15136719, }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE', probabilityScore: 0.11279297, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.10107422, }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE', probabilityScore: 0.048828125, severity: 'HARM_SEVERITY_NEGLIGIBLE', severityScore: 0.05493164, }, ], index: 0, }, ], usageMetadata: { promptTokenCount: 95, candidatesTokenCount: 9, totalTokenCount: 104 }, modelVersion: 'gemini-1.5-flash-001', }, ]; const mockGoogleStream = new ReadableStream({ start(controller) { rawChunks.forEach((chunk) => controller.enqueue(chunk)); controller.close(); }, }); const onStartMock = vi.fn(); const onTextMock = vi.fn(); const onToolCallMock = vi.fn(); const onCompletionMock = vi.fn(); const protocolStream = VertexAIStream(mockGoogleStream, { callbacks: { onStart: onStartMock, onText: onTextMock, onToolsCalling: onToolCallMock, onCompletion: onCompletionMock, }, }); const decoder = new TextDecoder(); const chunks = []; // @ts-ignore for await (const chunk of protocolStream) { chunks.push(decoder.decode(chunk, { stream: true })); } expect(chunks).toEqual([ // text 'id: chat_1\n', 'event: tool_calls\n', `data: [{"function":{"arguments":"{\\"city\\":\\"杭州\\"}","name":"realtime-weather____fetchCurrentWeather"},"id":"realtime-weather____fetchCurrentWeather_0","index":0,"type":"function"}]\n\n`, 'id: chat_1\n', 'event: stop\n', 'data: "STOP"\n\n', 'id: chat_1\n', 'event: usage\n', 'data: {"outputTextTokens":9,"totalInputTokens":95,"totalOutputTokens":9,"totalTokens":104}\n\n', ]); expect(onStartMock).toHaveBeenCalledTimes(1); expect(onToolCallMock).toHaveBeenCalledTimes(1); expect(onCompletionMock).toHaveBeenCalledTimes(1); }); it('should handle stop with content', async () => { vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1'); const data = [ { candidates: [ { content: { parts: [{ text: '234' }], role: 'model' }, safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, ], }, ], text: () => '234', usageMetadata: { promptTokenCount: 20, totalTokenCount: 20, promptTokensDetails: [{ modality: 'TEXT', tokenCount: 20 }], }, modelVersion: 'gemini-2.0-flash-exp-image-generation', }, { text: () => '567890\n', candidates: [ { content: { parts: [{ text: '567890\n' }], role: 'model' }, finishReason: 'STOP', safetyRatings: [ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' }, { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' }, ], }, ], usageMetadata: { promptTokenCount: 19, candidatesTokenCount: 11, totalTokenCount: 30, promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }], candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }], }, modelVersion: 'gemini-2.0-flash-exp-image-generation', }, ]; const mockGoogleStream = new ReadableStream({ start(controller) { data.forEach((item) => { controller.enqueue(item); }); controller.close(); }, }); const protocolStream = VertexAIStream(mockGoogleStream); const decoder = new TextDecoder(); const chunks = []; // @ts-ignore for await (const chunk of protocolStream) { chunks.push(decoder.decode(chunk, { stream: true })); } expect(chunks).toEqual( [ 'id: chat_1', 'event: text', 'data: "234"\n', 'id: chat_1', 'event: text', `data: "567890\\n"\n`, // stop 'id: chat_1', 'event: stop', `data: "STOP"\n`, // usage 'id: chat_1', 'event: usage', `data: {"inputTextTokens":19,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`, ].map((i) => i + '\n'), ); }); });