Skip to content

Commit ea85128

Browse files
authored
fix(ai): Handle empty parts when streaming (#9262)
1 parent 7a7634f commit ea85128

File tree

5 files changed

+61
-22
lines changed

5 files changed

+61
-22
lines changed

.changeset/poor-cobras-dream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/ai': patch
3+
---
4+
5+
Updated SDK to handle empty parts when streaming.

packages/ai/src/googleai-mappers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export function mapGenerateContentCandidates(
176176
// Throw early since developers may send a long video as input and only expect to pay
177177
// for inference on a small portion of the video.
178178
if (
179-
candidate.content?.parts.some(
179+
candidate.content?.parts?.some(
180180
part => (part as InlineDataPart)?.videoMetadata
181181
)
182182
) {

packages/ai/src/methods/generate-content.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,22 @@ describe('generateContent()', () => {
386386
match.any
387387
);
388388
});
389+
it('empty part', async () => {
390+
const mockResponse = getMockResponse(
391+
'vertexAI',
392+
'unary-success-empty-part.json'
393+
);
394+
stub(request, 'makeRequest').resolves(mockResponse as Response);
395+
const result = await generateContent(
396+
fakeApiSettings,
397+
'model',
398+
fakeRequestParams
399+
);
400+
expect(result.response.text()).to.include(
401+
'I can certainly help you with that!'
402+
);
403+
expect(result.response.inlineDataParts()?.length).to.equal(1);
404+
});
389405
it('unknown enum - should ignore', async () => {
390406
const mockResponse = getMockResponse(
391407
'vertexAI',

packages/ai/src/requests/stream-reader.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,20 @@ describe('processStream', () => {
194194
expect(response.text()).to.equal('');
195195
}
196196
});
197+
it('handles empty parts', async () => {
198+
const fakeResponse = getMockResponseStreaming(
199+
'googleAI',
200+
'streaming-success-empty-parts.txt'
201+
);
202+
203+
const result = processStream(fakeResponse as Response, fakeApiSettings);
204+
for await (const response of result.stream) {
205+
expect(response.candidates?.[0].content.parts.length).to.be.at.least(1);
206+
}
207+
208+
const aggregatedResponse = await result.response;
209+
expect(aggregatedResponse.candidates?.[0].content.parts.length).to.equal(6);
210+
});
197211
it('unknown enum - should ignore', async () => {
198212
const fakeResponse = getMockResponseStreaming(
199213
'vertexAI',

packages/ai/src/requests/stream-reader.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,17 @@ async function* generateResponseSequence(
100100
enhancedResponse = createEnhancedContentResponse(value);
101101
}
102102

103+
const firstCandidate = enhancedResponse.candidates?.[0];
104+
// Don't yield a response with no useful data for the developer.
105+
if (
106+
!firstCandidate?.content?.parts &&
107+
!firstCandidate?.finishReason &&
108+
!firstCandidate?.citationMetadata &&
109+
!firstCandidate?.urlContextMetadata
110+
) {
111+
continue;
112+
}
113+
103114
yield enhancedResponse;
104115
}
105116
}
@@ -211,37 +222,30 @@ export function aggregateResponses(
211222
* Candidates should always have content and parts, but this handles
212223
* possible malformed responses.
213224
*/
214-
if (candidate.content && candidate.content.parts) {
225+
if (candidate.content) {
226+
// Skip a candidate without parts.
227+
if (!candidate.content.parts) {
228+
continue;
229+
}
215230
if (!aggregatedResponse.candidates[i].content) {
216231
aggregatedResponse.candidates[i].content = {
217232
role: candidate.content.role || 'user',
218233
parts: []
219234
};
220235
}
221-
const newPart: Partial<Part> = {};
222236
for (const part of candidate.content.parts) {
223-
if (part.text !== undefined) {
224-
// The backend can send empty text parts. If these are sent back
225-
// (e.g. in chat history), the backend will respond with an error.
226-
// To prevent this, ignore empty text parts.
227-
if (part.text === '') {
228-
continue;
229-
}
230-
newPart.text = part.text;
231-
}
232-
if (part.functionCall) {
233-
newPart.functionCall = part.functionCall;
237+
const newPart: Part = { ...part };
238+
// The backend can send empty text parts. If these are sent back
239+
// (e.g. in chat history), the backend will respond with an error.
240+
// To prevent this, ignore empty text parts.
241+
if (part.text === '') {
242+
continue;
234243
}
235-
if (Object.keys(newPart).length === 0) {
236-
throw new AIError(
237-
AIErrorCode.INVALID_CONTENT,
238-
'Part should have at least one property, but there are none. This is likely caused ' +
239-
'by a malformed response from the backend.'
244+
if (Object.keys(newPart).length > 0) {
245+
aggregatedResponse.candidates[i].content.parts.push(
246+
newPart as Part
240247
);
241248
}
242-
aggregatedResponse.candidates[i].content.parts.push(
243-
newPart as Part
244-
);
245249
}
246250
}
247251
}

0 commit comments

Comments
 (0)