Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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/seven-oranges-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/vertexai': patch
---

Filter out empty text parts from streaming responses.
120 changes: 120 additions & 0 deletions packages/vertexai/src/requests/stream-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import {
aggregateResponses,
deleteEmptyTextParts,
getResponseStream,
processStream
} from './stream-reader';
Expand All @@ -33,6 +34,7 @@ import {
GenerateContentResponse,
HarmCategory,
HarmProbability,
Part,
SafetyRating
} from '../types';

Expand Down Expand Up @@ -220,6 +222,23 @@ describe('processStream', () => {
}
expect(foundCitationMetadata).to.be.true;
});
it('removes empty text parts', async () => {
const fakeResponse = getMockResponseStreaming(
'streaming-success-empty-text-part.txt'
);
const result = processStream(fakeResponse as Response);
const aggregatedResponse = await result.response;
expect(aggregatedResponse.text()).to.equal('1');
expect(aggregatedResponse.candidates?.length).to.equal(1);
expect(aggregatedResponse.candidates?.[0].content.parts.length).to.equal(1);

// The chunk with the empty text part will still go through the stream
let numChunks = 0;
for await (const _ of result.stream) {
numChunks++;
}
expect(numChunks).to.equal(2);
});
});

describe('aggregateResponses', () => {
Expand Down Expand Up @@ -404,3 +423,104 @@ describe('aggregateResponses', () => {
});
});
});

describe('deleteEmptyTextParts', () => {
it('removes empty text parts from a single candidate', () => {
const parts: Part[] = [
{
text: ''
},
{
text: 'foo'
}
];
const generateContentResponse: GenerateContentResponse = {
candidates: [
{
index: 0,
content: {
role: 'model',
parts
}
}
]
};

deleteEmptyTextParts(generateContentResponse);
expect(generateContentResponse.candidates?.[0].content.parts).to.deep.equal(
[
{
text: 'foo'
}
]
);
});
it('removes empty text parts from all candidates', () => {
const parts: Part[] = [
{
text: ''
},
{
text: 'foo'
}
];
const generateContentResponse: GenerateContentResponse = {
candidates: [
{
index: 0,
content: {
role: 'model',
parts
}
},
{
index: 1,
content: {
role: 'model',
parts
}
}
]
};

deleteEmptyTextParts(generateContentResponse);
expect(generateContentResponse.candidates?.[0].content.parts).to.deep.equal(
[
{
text: 'foo'
}
]
);
expect(generateContentResponse.candidates?.[1].content.parts).to.deep.equal(
[
{
text: 'foo'
}
]
);
});
it('does not remove candidate even if all parts are removed', () => {
const parts: Part[] = [
{
text: ''
}
];
const generateContentResponse: GenerateContentResponse = {
candidates: [
{
index: 0,
content: {
role: 'model',
parts
}
}
]
};

deleteEmptyTextParts(generateContentResponse);
expect(generateContentResponse.candidates?.length).to.equal(1);
expect(generateContentResponse.candidates?.[0].content.parts).to.deep.equal(
[]
);
});
});
21 changes: 21 additions & 0 deletions packages/vertexai/src/requests/stream-reader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ async function getResponsePromise(
);
return enhancedResponse;
}

deleteEmptyTextParts(value);
allResponses.push(value);
}
}
Expand All @@ -76,6 +78,7 @@ async function* generateResponseSequence(
break;
}

deleteEmptyTextParts(value);
const enhancedResponse = createEnhancedContentResponse(value);
yield enhancedResponse;
}
Expand Down Expand Up @@ -203,3 +206,21 @@ export function aggregateResponses(
}
return aggregatedResponse;
}

/**
* The backend can send empty text parts, but if they are sent back (e.g. in a chat history) there
* will be an error. To prevent this, filter out the empty text part from responses.
*
* See: https://github.com/firebase/firebase-js-sdk/issues/8714
*/
export function deleteEmptyTextParts(response: GenerateContentResponse): void {
if (response.candidates) {
response.candidates.forEach(candidate => {
if (candidate.content && candidate.content.parts) {
candidate.content.parts = candidate.content.parts.filter(
part => part.text !== ''
);
}
});
}
}
2 changes: 1 addition & 1 deletion scripts/update_vertexai_responses.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# This script replaces mock response files for Vertex AI unit tests with a fresh
# clone of the shared repository of Vertex AI test data.

RESPONSES_VERSION='v5.*' # The major version of mock responses to use
RESPONSES_VERSION='v6.*' # The major version of mock responses to use
REPO_NAME="vertexai-sdk-test-data"
REPO_LINK="https://github.com/FirebaseExtended/$REPO_NAME.git"

Expand Down
Loading