From 4de81936e97bf527d61860de294380b275d6c06d Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Wed, 17 Sep 2025 13:20:55 -0700 Subject: [PATCH 1/3] Handle empty parts when streaming. --- .changeset/poor-cobras-dream.md | 5 ++++ packages/ai/src/googleai-mappers.ts | 2 +- .../ai/src/methods/generate-content.test.ts | 16 +++++++++++ .../ai/src/requests/stream-reader.test.ts | 14 ++++++++++ packages/ai/src/requests/stream-reader.ts | 27 ++++++++++--------- 5 files changed, 50 insertions(+), 14 deletions(-) create mode 100644 .changeset/poor-cobras-dream.md diff --git a/.changeset/poor-cobras-dream.md b/.changeset/poor-cobras-dream.md new file mode 100644 index 00000000000..3ff9e7bc7bd --- /dev/null +++ b/.changeset/poor-cobras-dream.md @@ -0,0 +1,5 @@ +--- +'@firebase/ai': patch +--- + +Updated SDK to handle empty parts when streaming. diff --git a/packages/ai/src/googleai-mappers.ts b/packages/ai/src/googleai-mappers.ts index 23c238c1e3b..3f2dbfe6838 100644 --- a/packages/ai/src/googleai-mappers.ts +++ b/packages/ai/src/googleai-mappers.ts @@ -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 ) ) { diff --git a/packages/ai/src/methods/generate-content.test.ts b/packages/ai/src/methods/generate-content.test.ts index 3bb396ac6d8..f2e2d6950a1 100644 --- a/packages/ai/src/methods/generate-content.test.ts +++ b/packages/ai/src/methods/generate-content.test.ts @@ -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', diff --git a/packages/ai/src/requests/stream-reader.test.ts b/packages/ai/src/requests/stream-reader.test.ts index f0298082f68..2e50bbb3d3e 100644 --- a/packages/ai/src/requests/stream-reader.test.ts +++ b/packages/ai/src/requests/stream-reader.test.ts @@ -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', diff --git a/packages/ai/src/requests/stream-reader.ts b/packages/ai/src/requests/stream-reader.ts index c3a35b1da4a..4492ea916b1 100644 --- a/packages/ai/src/requests/stream-reader.ts +++ b/packages/ai/src/requests/stream-reader.ts @@ -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; } } @@ -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 = {}; 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. @@ -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 - ); } } } From df0cf7e020f8d5eef6a3ddd3dff244fe2f48c609 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 23 Sep 2025 13:17:06 -0700 Subject: [PATCH 2/3] Update logic for skipping a respons during streaming --- packages/ai/src/requests/stream-reader.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/ai/src/requests/stream-reader.ts b/packages/ai/src/requests/stream-reader.ts index 4492ea916b1..bd227c51c29 100644 --- a/packages/ai/src/requests/stream-reader.ts +++ b/packages/ai/src/requests/stream-reader.ts @@ -100,8 +100,13 @@ async function* generateResponseSequence( enhancedResponse = createEnhancedContentResponse(value); } - // Don't yield an empty parts response, skip it. - if (!enhancedResponse.candidates?.[0].content?.parts) { + 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 + ) { continue; } @@ -215,14 +220,11 @@ export function aggregateResponses( } 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. - // To prevent this, ignore empty text parts. - if (part.text === '') { - continue; - } - newPart.text = part.text; + // 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) { aggregatedResponse.candidates[i].content.parts.push( From 360d0cdd3b64d113721b76204787cbff455a1c7d Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Fri, 26 Sep 2025 10:59:09 -0700 Subject: [PATCH 3/3] add urlContextMetadata --- packages/ai/src/requests/stream-reader.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/requests/stream-reader.ts b/packages/ai/src/requests/stream-reader.ts index 333834ca432..042c052fa82 100644 --- a/packages/ai/src/requests/stream-reader.ts +++ b/packages/ai/src/requests/stream-reader.ts @@ -105,7 +105,8 @@ async function* generateResponseSequence( if ( !firstCandidate?.content?.parts && !firstCandidate?.finishReason && - !firstCandidate?.citationMetadata + !firstCandidate?.citationMetadata && + !firstCandidate?.urlContextMetadata ) { continue; }