Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/poor-cobras-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/ai': patch
---

Updated SDK to handle empty parts when streaming.
2 changes: 1 addition & 1 deletion packages/ai/src/googleai-mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ export function mapGenerateContentCandidates(
// Throw early since developers may send a long video as input and only expect to pay
// for inference on a small portion of the video.
if (
candidate.content?.parts.some(
candidate.content?.parts?.some(
part => (part as InlineDataPart)?.videoMetadata
)
) {
Expand Down
16 changes: 16 additions & 0 deletions packages/ai/src/methods/generate-content.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,22 @@ describe('generateContent()', () => {
match.any
);
});
it('empty part', async () => {
const mockResponse = getMockResponse(
'vertexAI',
'unary-success-empty-part.json'
);
stub(request, 'makeRequest').resolves(mockResponse as Response);
const result = await generateContent(
fakeApiSettings,
'model',
fakeRequestParams
);
expect(result.response.text()).to.include(
'I can certainly help you with that!'
);
expect(result.response.inlineDataParts()?.length).to.equal(1);
});
it('unknown enum - should ignore', async () => {
const mockResponse = getMockResponse(
'vertexAI',
Expand Down
14 changes: 14 additions & 0 deletions packages/ai/src/requests/stream-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,20 @@ describe('processStream', () => {
expect(response.text()).to.equal('');
}
});
it('handles empty parts', async () => {
const fakeResponse = getMockResponseStreaming(
'googleAI',
'streaming-success-empty-parts.txt'
);

const result = processStream(fakeResponse as Response, fakeApiSettings);
for await (const response of result.stream) {
expect(response.candidates?.[0].content.parts.length).to.be.at.least(1);
}

const aggregatedResponse = await result.response;
expect(aggregatedResponse.candidates?.[0].content.parts.length).to.equal(6);
});
it('unknown enum - should ignore', async () => {
const fakeResponse = getMockResponseStreaming(
'vertexAI',
Expand Down
27 changes: 14 additions & 13 deletions packages/ai/src/requests/stream-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,11 @@ async function* generateResponseSequence(
enhancedResponse = createEnhancedContentResponse(value);
}

// Don't yield an empty parts response, skip it.
if (!enhancedResponse.candidates?.[0].content?.parts) {
continue;
}

yield enhancedResponse;
}
}
Expand Down Expand Up @@ -197,15 +202,19 @@ export function aggregateResponses(
* Candidates should always have content and parts, but this handles
* possible malformed responses.
*/
if (candidate.content && candidate.content.parts) {
if (candidate.content) {
// Skip a candidate without parts.
if (!candidate.content.parts) {
continue;
}
if (!aggregatedResponse.candidates[i].content) {
aggregatedResponse.candidates[i].content = {
role: candidate.content.role || 'user',
parts: []
};
}
const newPart: Partial<Part> = {};
for (const part of candidate.content.parts) {
const newPart: Part = { ...part };
if (part.text !== undefined) {
// The backend can send empty text parts. If these are sent back
// (e.g. in chat history), the backend will respond with an error.
Expand All @@ -215,19 +224,11 @@ export function aggregateResponses(
}
newPart.text = part.text;
}
if (part.functionCall) {
newPart.functionCall = part.functionCall;
}
if (Object.keys(newPart).length === 0) {
throw new AIError(
AIErrorCode.INVALID_CONTENT,
'Part should have at least one property, but there are none. This is likely caused ' +
'by a malformed response from the backend.'
if (Object.keys(newPart).length > 0) {
aggregatedResponse.candidates[i].content.parts.push(
newPart as Part
);
}
aggregatedResponse.candidates[i].content.parts.push(
newPart as Part
);
}
}
}
Expand Down
Loading