Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filter empty text parts when streaming #8736

Merged
merged 10 commits into from
Feb 12, 2025
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