Skip to content

Commit 9f4c34b

Browse files
authored
[Firebase AI] Handle empty or unknown Part data (#15262)
1 parent 0b46d74 commit 9f4c34b

File tree

10 files changed

+223
-38
lines changed

10 files changed

+223
-38
lines changed

FirebaseAI/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# 12.3.0
2+
- [fixed] Fixed a decoding error when generating images with the
3+
`gemini-2.5-flash-image-preview` model using `generateContentStream` or
4+
`sendMessageStream` with the Gemini Developer API. (#15262)
5+
16
# 12.2.0
27
- [feature] Added support for returning thought summaries, which are synthesized
38
versions of a model's internal reasoning process. (#15096)

FirebaseAI/Sources/AILog.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,13 @@ enum AILog {
6262
case decodedInvalidCitationPublicationDate = 3011
6363
case generateContentResponseUnrecognizedContentModality = 3012
6464
case decodedUnsupportedImagenPredictionType = 3013
65+
case decodedUnsupportedPartData = 3014
6566

6667
// SDK State Errors
6768
case generateContentResponseNoCandidates = 4000
6869
case generateContentResponseNoText = 4001
6970
case appCheckTokenFetchFailed = 4002
71+
case generateContentResponseEmptyCandidates = 4003
7072

7173
// SDK Debugging
7274
case loadRequestStreamResponseLine = 5000

FirebaseAI/Sources/GenerateContentResponse.swift

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,12 @@ public struct Candidate: Sendable {
163163
self.citationMetadata = citationMetadata
164164
self.groundingMetadata = groundingMetadata
165165
}
166+
167+
// Returns `true` if the candidate contains no information that a developer could use.
168+
var isEmpty: Bool {
169+
content.parts
170+
.isEmpty && finishReason == nil && citationMetadata == nil && groundingMetadata == nil
171+
}
166172
}
167173

168174
/// A collection of source attributions for a piece of content.
@@ -525,15 +531,6 @@ extension Candidate: Decodable {
525531

526532
finishReason = try container.decodeIfPresent(FinishReason.self, forKey: .finishReason)
527533

528-
// The `content` may only be empty if a `finishReason` is included; if neither are included in
529-
// the response then this is likely the `"content": {}` bug.
530-
guard !content.parts.isEmpty || finishReason != nil else {
531-
throw InvalidCandidateError.emptyContent(underlyingError: DecodingError.dataCorrupted(.init(
532-
codingPath: [CodingKeys.content, CodingKeys.finishReason],
533-
debugDescription: "Invalid Candidate: empty content and no finish reason"
534-
)))
535-
}
536-
537534
citationMetadata = try container.decodeIfPresent(
538535
CitationMetadata.self,
539536
forKey: .citationMetadata

FirebaseAI/Sources/GenerativeModel.swift

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,13 @@ public final class GenerativeModel: Sendable {
174174
throw GenerateContentError.responseStoppedEarly(reason: reason, response: response)
175175
}
176176

177+
// If all candidates are empty (contain no information that a developer could act on) then throw
178+
if response.candidates.allSatisfy({ $0.isEmpty }) {
179+
throw GenerateContentError.internalError(underlying: InvalidCandidateError.emptyContent(
180+
underlyingError: Candidate.EmptyContentError()
181+
))
182+
}
183+
177184
return response
178185
}
179186

@@ -223,6 +230,7 @@ public final class GenerativeModel: Sendable {
223230
let responseStream = generativeAIService.loadRequestStream(request: generateContentRequest)
224231
Task {
225232
do {
233+
var didYieldResponse = false
226234
for try await response in responseStream {
227235
// Check the prompt feedback to see if the prompt was blocked.
228236
if response.promptFeedback?.blockReason != nil {
@@ -237,9 +245,30 @@ public final class GenerativeModel: Sendable {
237245
)
238246
}
239247

240-
continuation.yield(response)
248+
// Skip returning the response if all candidates are empty (i.e., they contain no
249+
// information that a developer could act on).
250+
if response.candidates.allSatisfy({ $0.isEmpty }) {
251+
AILog.log(
252+
level: .debug,
253+
code: .generateContentResponseEmptyCandidates,
254+
"Skipped response with all empty candidates: \(response)"
255+
)
256+
} else {
257+
continuation.yield(response)
258+
didYieldResponse = true
259+
}
260+
}
261+
262+
// Throw an error if all responses were skipped due to empty content.
263+
if didYieldResponse {
264+
continuation.finish()
265+
} else {
266+
continuation.finish(throwing: GenerativeModel.generateContentError(
267+
from: InvalidCandidateError.emptyContent(
268+
underlyingError: Candidate.EmptyContentError()
269+
)
270+
))
241271
}
242-
continuation.finish()
243272
} catch {
244273
continuation.finish(throwing: GenerativeModel.generateContentError(from: error))
245274
return

FirebaseAI/Sources/ModelContent.swift

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,17 @@ struct InternalPart: Equatable, Sendable {
3939
case fileData(FileData)
4040
case functionCall(FunctionCall)
4141
case functionResponse(FunctionResponse)
42+
43+
struct UnsupportedDataError: Error {
44+
let decodingError: DecodingError
45+
46+
var localizedDescription: String {
47+
decodingError.localizedDescription
48+
}
49+
}
4250
}
4351

44-
let data: OneOfData
52+
let data: OneOfData?
4553

4654
let isThought: Bool?
4755

@@ -65,7 +73,7 @@ public struct ModelContent: Equatable, Sendable {
6573

6674
/// The data parts comprising this ``ModelContent`` value.
6775
public var parts: [any Part] {
68-
return internalParts.map { part -> any Part in
76+
return internalParts.compactMap { part -> (any Part)? in
6977
switch part.data {
7078
case let .text(text):
7179
return TextPart(text, isThought: part.isThought, thoughtSignature: part.thoughtSignature)
@@ -85,6 +93,9 @@ public struct ModelContent: Equatable, Sendable {
8593
return FunctionResponsePart(
8694
functionResponse, isThought: part.isThought, thoughtSignature: part.thoughtSignature
8795
)
96+
case .none:
97+
// Filter out parts that contain missing or unrecognized data
98+
return nil
8899
}
89100
}
90101
}
@@ -179,7 +190,14 @@ extension InternalPart: Codable {
179190
}
180191

181192
public init(from decoder: Decoder) throws {
182-
data = try OneOfData(from: decoder)
193+
do {
194+
data = try OneOfData(from: decoder)
195+
} catch let error as OneOfData.UnsupportedDataError {
196+
AILog.error(code: .decodedUnsupportedPartData, error.localizedDescription)
197+
data = nil
198+
} catch { // Re-throw any other error types
199+
throw error
200+
}
183201
let container = try decoder.container(keyedBy: CodingKeys.self)
184202
isThought = try container.decodeIfPresent(Bool.self, forKey: .isThought)
185203
thoughtSignature = try container.decodeIfPresent(String.self, forKey: .thoughtSignature)
@@ -226,9 +244,11 @@ extension InternalPart.OneOfData: Codable {
226244
self = try .functionResponse(values.decode(FunctionResponse.self, forKey: .functionResponse))
227245
} else {
228246
let unexpectedKeys = values.allKeys.map { $0.stringValue }
229-
throw DecodingError.dataCorrupted(DecodingError.Context(
230-
codingPath: values.codingPath,
231-
debugDescription: "Unexpected Part type(s): \(unexpectedKeys)"
247+
throw UnsupportedDataError(decodingError: DecodingError.dataCorrupted(
248+
DecodingError.Context(
249+
codingPath: values.codingPath,
250+
debugDescription: "Unexpected Part type(s): \(unexpectedKeys)"
251+
)
232252
))
233253
}
234254
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *)
16+
extension Candidate {
17+
struct EmptyContentError: Error {
18+
let localizedDescription = "Invalid Candidate: empty content and no finish reason"
19+
}
20+
}

FirebaseAI/Tests/TestApp/Sources/Constants.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public enum ModelNames {
2424
public static let gemini2Flash = "gemini-2.0-flash-001"
2525
public static let gemini2FlashLite = "gemini-2.0-flash-lite-001"
2626
public static let gemini2FlashPreviewImageGeneration = "gemini-2.0-flash-preview-image-generation"
27+
public static let gemini2_5_FlashImagePreview = "gemini-2.5-flash-image-preview"
2728
public static let gemini2_5_Flash = "gemini-2.5-flash"
2829
public static let gemini2_5_Pro = "gemini-2.5-pro"
2930
public static let gemma3_4B = "gemma-3-4b-it"

FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -322,14 +322,20 @@ struct GenerateContentIntegrationTests {
322322
}
323323

324324
@Test(arguments: [
325-
InstanceConfig.vertexAI_v1beta,
326-
InstanceConfig.vertexAI_v1beta_global,
327-
InstanceConfig.googleAI_v1beta,
325+
(InstanceConfig.vertexAI_v1beta, ModelNames.gemini2FlashPreviewImageGeneration),
326+
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2FlashPreviewImageGeneration),
327+
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2_5_FlashImagePreview),
328+
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2FlashPreviewImageGeneration),
329+
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2_5_FlashImagePreview),
328330
// Note: The following configs are commented out for easy one-off manual testing.
329-
// InstanceConfig.googleAI_v1beta_staging,
330-
// InstanceConfig.googleAI_v1beta_freeTier_bypassProxy,
331+
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2FlashPreviewImageGeneration)
332+
// (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemini2FlashPreviewImageGeneration),
333+
// (
334+
// InstanceConfig.googleAI_v1beta_freeTier_bypassProxy,
335+
// ModelNames.gemini2FlashPreviewImageGeneration
336+
// ),
331337
])
332-
func generateImage(_ config: InstanceConfig) async throws {
338+
func generateImage(_ config: InstanceConfig, modelName: String) async throws {
333339
let generationConfig = GenerationConfig(
334340
temperature: 0.0,
335341
topP: 0.0,
@@ -342,7 +348,7 @@ struct GenerateContentIntegrationTests {
342348
$0.harmCategory != .civicIntegrity
343349
}
344350
let model = FirebaseAI.componentInstance(config).generativeModel(
345-
modelName: ModelNames.gemini2FlashPreviewImageGeneration,
351+
modelName: modelName,
346352
generationConfig: generationConfig,
347353
safetySettings: safetySettings
348354
)
@@ -483,6 +489,73 @@ struct GenerateContentIntegrationTests {
483489
#expect(response == expectedResponse)
484490
}
485491

492+
@Test(arguments: [
493+
(InstanceConfig.vertexAI_v1beta, ModelNames.gemini2FlashPreviewImageGeneration),
494+
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2FlashPreviewImageGeneration),
495+
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2_5_FlashImagePreview),
496+
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2FlashPreviewImageGeneration),
497+
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2_5_FlashImagePreview),
498+
// Note: The following configs are commented out for easy one-off manual testing.
499+
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2FlashPreviewImageGeneration)
500+
// (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemini2FlashPreviewImageGeneration),
501+
// (
502+
// InstanceConfig.googleAI_v1beta_freeTier_bypassProxy,
503+
// ModelNames.gemini2FlashPreviewImageGeneration
504+
// ),
505+
])
506+
func generateImageStreaming(_ config: InstanceConfig, modelName: String) async throws {
507+
let generationConfig = GenerationConfig(
508+
temperature: 0.0,
509+
topP: 0.0,
510+
topK: 1,
511+
responseModalities: [.text, .image]
512+
)
513+
let safetySettings = safetySettings.filter {
514+
// HARM_CATEGORY_CIVIC_INTEGRITY is deprecated in Vertex AI but only rejected when using the
515+
// 'gemini-2.0-flash-preview-image-generation' model.
516+
$0.harmCategory != .civicIntegrity
517+
}
518+
let model = FirebaseAI.componentInstance(config).generativeModel(
519+
modelName: modelName,
520+
generationConfig: generationConfig,
521+
safetySettings: safetySettings
522+
)
523+
let prompt = "Generate an image of a cute cartoon kitten playing with a ball of yarn"
524+
525+
let stream = try model.generateContentStream(prompt)
526+
527+
var inlineDataParts = [InlineDataPart]()
528+
for try await response in stream {
529+
let candidate = try #require(response.candidates.first)
530+
let inlineDataPart = candidate.content.parts.first { $0 is InlineDataPart } as? InlineDataPart
531+
if let inlineDataPart {
532+
inlineDataParts.append(inlineDataPart)
533+
let inlineDataPartsViaAccessor = response.inlineDataParts
534+
#expect(inlineDataPartsViaAccessor.count == 1)
535+
#expect(inlineDataPartsViaAccessor == response.inlineDataParts)
536+
}
537+
let textPart = candidate.content.parts.first { $0 is TextPart } as? TextPart
538+
#expect(
539+
inlineDataPart != nil || textPart != nil || candidate.finishReason == .stop,
540+
"No text or image found in the candidate"
541+
)
542+
}
543+
544+
#expect(inlineDataParts.count == 1)
545+
let inlineDataPart = try #require(inlineDataParts.first)
546+
#expect(inlineDataPart.mimeType == "image/png")
547+
#expect(inlineDataPart.data.count > 0)
548+
#if canImport(UIKit)
549+
let uiImage = try #require(UIImage(data: inlineDataPart.data))
550+
// Gemini 2.0 Flash Experimental returns images sized to fit within a 1024x1024 pixel box but
551+
// dimensions may vary depending on the aspect ratio.
552+
#expect(uiImage.size.width <= 1024)
553+
#expect(uiImage.size.width >= 500)
554+
#expect(uiImage.size.height <= 1024)
555+
#expect(uiImage.size.height >= 500)
556+
#endif // canImport(UIKit)
557+
}
558+
486559
// MARK: - App Check Tests
487560

488561
@Test(arguments: InstanceConfig.appCheckNotConfiguredConfigs)

FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,23 @@ final class GenerativeModelGoogleAITests: XCTestCase {
509509
XCTAssertTrue(thoughtSignature.hasPrefix("CiIBVKhc7vB+vaaq6rA"))
510510
}
511511

512+
func testGenerateContentStream_success_ignoresEmptyParts() async throws {
513+
MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler(
514+
forResource: "streaming-success-empty-parts",
515+
withExtension: "txt",
516+
subdirectory: googleAISubdirectory
517+
)
518+
519+
let stream = try model.generateContentStream("Hi")
520+
for try await response in stream {
521+
let candidate = try XCTUnwrap(response.candidates.first)
522+
XCTAssertGreaterThan(candidate.content.parts.count, 0)
523+
let text = response.text
524+
let inlineData = response.inlineDataParts.first
525+
XCTAssertTrue(text != nil || inlineData != nil, "Response did not contain text or data")
526+
}
527+
}
528+
512529
func testGenerateContentStream_failureInvalidAPIKey() async throws {
513530
MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler(
514531
forResource: "unary-failure-api-key",

0 commit comments

Comments
 (0)