Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 @@ -386,6 +386,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
46 changes: 25 additions & 21 deletions packages/ai/src/requests/stream-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,17 @@ async function* generateResponseSequence(
enhancedResponse = createEnhancedContentResponse(value);
}

const firstCandidate = enhancedResponse.candidates?.[0];
// Don't yield a response with no useful data for the developer.
if (
!firstCandidate?.content?.parts &&
!firstCandidate?.finishReason &&
!firstCandidate?.citationMetadata &&
!firstCandidate?.urlContextMetadata
) {
continue;
}

yield enhancedResponse;
}
}
Expand Down Expand Up @@ -211,37 +222,30 @@ 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) {
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.
// To prevent this, ignore empty text parts.
if (part.text === '') {
continue;
}
newPart.text = part.text;
}
if (part.functionCall) {
newPart.functionCall = part.functionCall;
const newPart: Part = { ...part };
// The backend can send empty text parts. If these are sent back
// (e.g. in chat history), the backend will respond with an error.
// To prevent this, ignore empty text parts.
if (part.text === '') {
continue;
}
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