Skip to content

Commit 9e9952c

Browse files
committed
fix: optimize Gemini stream parsing
1 parent 747c0a6 commit 9e9952c

2 files changed

Lines changed: 308 additions & 74 deletions

File tree

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
3+
import { __testGoogleRequestsAPI } from './google'
4+
5+
const mocks = vi.hoisted(() => ({
6+
db: {},
7+
fetchNative: vi.fn(),
8+
saveInlayedSignature: vi.fn(),
9+
setInlayAsset: vi.fn(),
10+
writeInlayImage: vi.fn(),
11+
v4: vi.fn(),
12+
}))
13+
14+
vi.mock('src/ts/globalApi.svelte', () => ({
15+
addFetchLog: vi.fn(),
16+
fetchNative: mocks.fetchNative,
17+
textifyReadableStream: vi.fn(),
18+
}))
19+
20+
vi.mock('src/ts/model/modellist', () => ({
21+
LLMFlags: {
22+
hasAudioInput: 2,
23+
hasImageInput: 1,
24+
hasVideoInput: 3,
25+
},
26+
LLMFormat: {
27+
GoogleCloud: 5,
28+
VertexAIGemini: 6,
29+
},
30+
}))
31+
32+
vi.mock('src/ts/storage/database.svelte', () => ({
33+
getDatabase: () => mocks.db,
34+
setDatabase: vi.fn(),
35+
}))
36+
37+
vi.mock('src/ts/util', () => ({
38+
base64url: (data: string) => data,
39+
simplifySchema: (schema: unknown) => schema,
40+
}))
41+
42+
vi.mock('../files/inlays', () => ({
43+
saveInlayedSignature: mocks.saveInlayedSignature,
44+
setInlayAsset: mocks.setInlayAsset,
45+
writeInlayImage: mocks.writeInlayImage,
46+
}))
47+
48+
vi.mock('../templates/jsonSchema', () => ({
49+
extractJSON: (data: string) => data,
50+
getGeneralJSONSchema: () => ({}),
51+
}))
52+
53+
vi.mock('../mcp/mcp', () => ({
54+
callTool: vi.fn(),
55+
decodeToolCall: vi.fn(),
56+
encodeToolCall: vi.fn(),
57+
}))
58+
59+
vi.mock('src/ts/alert', () => ({
60+
alertError: vi.fn(),
61+
}))
62+
63+
vi.mock('src/ts/stores.svelte', () => ({
64+
bodyIntercepterStore: [],
65+
}))
66+
67+
vi.mock('uuid', () => ({
68+
v4: mocks.v4,
69+
}))
70+
71+
const modelInfo = {
72+
format: 5,
73+
id: 'gemini-test',
74+
internalID: 'gemini-test',
75+
} as any
76+
77+
async function collectStream(stream: ReadableStream<Record<string, string>>) {
78+
const reader = stream.getReader()
79+
const chunks: Record<string, string>[] = []
80+
while(true){
81+
const { done, value } = await reader.read()
82+
if(done){
83+
return chunks
84+
}
85+
chunks.push(value)
86+
}
87+
}
88+
89+
describe('Google/Gemini stream parser', () => {
90+
beforeEach(() => {
91+
mocks.fetchNative.mockReset()
92+
mocks.saveInlayedSignature.mockReset()
93+
mocks.setInlayAsset.mockReset()
94+
mocks.writeInlayImage.mockReset()
95+
mocks.v4.mockReset()
96+
})
97+
98+
it('keeps only partial line text buffered and parses each completed SSE line once', async () => {
99+
const stream = __testGoogleRequestsAPI.getTranStream({
100+
modelInfo,
101+
saveSignature: false,
102+
})
103+
const chunksPromise = collectStream(stream.readable)
104+
const writer = stream.writable.getWriter()
105+
const encoder = new TextEncoder()
106+
const parseSpy = vi.spyOn(JSON, 'parse')
107+
108+
try{
109+
await writer.write(encoder.encode('data: {"candidates":[{"content":{"parts":[{"text":"Hel"}]}}]}\n\n'))
110+
await writer.write(encoder.encode('data: {"candidates":[{"content":{"parts":[{"text":"lo"}]}}]}\n\n'))
111+
await writer.write(encoder.encode('data: {"candidates":[{"content":{"parts":[{"text":"!"}]}}]}\n\n'))
112+
await writer.write(encoder.encode('data: [DONE]\n\n'))
113+
await writer.close()
114+
115+
const chunks = await chunksPromise
116+
expect(chunks.at(-1)?.['0']).toBe('Hello!')
117+
expect(parseSpy).toHaveBeenCalledTimes(3)
118+
}
119+
finally{
120+
parseSpy.mockRestore()
121+
}
122+
})
123+
124+
it('preserves split UTF-8 text across chunk boundaries', async () => {
125+
const stream = __testGoogleRequestsAPI.getTranStream({
126+
modelInfo,
127+
saveSignature: false,
128+
})
129+
const chunksPromise = collectStream(stream.readable)
130+
const writer = stream.writable.getWriter()
131+
const encoder = new TextEncoder()
132+
const bytes = encoder.encode('data: {"candidates":[{"content":{"parts":[{"text":"Hi 😀"}]}}]}\n\n')
133+
const emojiStart = bytes.findIndex((byte, index) => byte === 0xf0 && bytes[index + 1] === 0x9f)
134+
135+
await writer.write(bytes.slice(0, emojiStart + 2))
136+
await writer.write(bytes.slice(emojiStart + 2))
137+
await writer.close()
138+
139+
const chunks = await chunksPromise
140+
expect(chunks.at(-1)?.['0']).toBe('Hi 😀')
141+
})
142+
143+
it('parses split lines and runs signature side effects only once per new event', async () => {
144+
mocks.v4.mockReturnValueOnce('sig-text-id').mockReturnValueOnce('sig-fn-id')
145+
146+
const stream = __testGoogleRequestsAPI.getTranStream({
147+
modelInfo,
148+
saveSignature: true,
149+
})
150+
const chunksPromise = collectStream(stream.readable)
151+
const writer = stream.writable.getWriter()
152+
const encoder = new TextEncoder()
153+
const signedCallEvent = 'data: {"candidates":[{"content":{"parts":[{"text":"Thinking","thought":true,"thoughtSignature":"sig-text"},{"functionCall":{"name":"lookup","args":{"q":"x"}},"thoughtSignature":"sig-fn"}]}}]}\n\n'
154+
155+
await writer.write(encoder.encode(signedCallEvent.slice(0, 72)))
156+
await writer.write(encoder.encode(signedCallEvent.slice(72)))
157+
await writer.write(encoder.encode('data: {"candidates":[{"content":{"parts":[{"text":"Answer"}]}}],"usageMetadata":{"totalTokenCount":9},"modelStatus":{"status":"ok"}}\n\n'))
158+
await writer.close()
159+
160+
const chunks = await chunksPromise
161+
const lastChunk = chunks.at(-1) ?? {}
162+
163+
expect(lastChunk['0']).toBe('{{inlayeddata::sig-text-id}}{{inlayeddata::sig-fn-id}}Answer')
164+
expect(lastChunk['__thoughts']).toBe('Thinking')
165+
expect(lastChunk['__last_thought']).toBe('')
166+
expect(lastChunk['__sign_text']).toBe('sig-text')
167+
expect(lastChunk['__sign_function']).toBe('sig-fn')
168+
expect(lastChunk['__tool_calls']).toBe(JSON.stringify([
169+
{
170+
name: 'lookup',
171+
args: {
172+
q: 'x',
173+
},
174+
},
175+
]))
176+
expect(lastChunk['__usageMetadata']).toBe(JSON.stringify({ totalTokenCount: 9 }))
177+
expect(lastChunk['__modelStatus']).toBe(JSON.stringify({ status: 'ok' }))
178+
expect(mocks.saveInlayedSignature).toHaveBeenCalledTimes(2)
179+
})
180+
})

0 commit comments

Comments
 (0)