Skip to content

Commit dd26bf8

Browse files
committed
Merge branch 'main' into ah/ai-code-execution
# Conflicts: # FirebaseAI/CHANGELOG.md # FirebaseAI/Sources/AILog.swift # FirebaseAI/Sources/ModelContent.swift
2 parents d94c062 + 9f4c34b commit dd26bf8

File tree

10 files changed

+223
-40
lines changed

10 files changed

+223
-40
lines changed

FirebaseAI/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
- [feature] Added support for the Code Execution tool, which enables the model
33
to generate and run code to perform complex tasks like solving mathematical
44
equations or visualizing data. (#15280)
5+
- [fixed] Fixed a decoding error when generating images with the
6+
`gemini-2.5-flash-image-preview` model using `generateContentStream` or
7+
`sendMessageStream` with the Gemini Developer API. (#15262)
58

69
# 12.2.0
710
- [feature] Added support for returning thought summaries, which are synthesized

FirebaseAI/Sources/AILog.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,15 @@ enum AILog {
6262
case decodedInvalidCitationPublicationDate = 3011
6363
case generateContentResponseUnrecognizedContentModality = 3012
6464
case decodedUnsupportedImagenPredictionType = 3013
65-
case codeExecutionResultUnrecognizedOutcome = 3014
66-
case executableCodeUnrecognizedLanguage = 3015
65+
case decodedUnsupportedPartData = 3014
66+
case codeExecutionResultUnrecognizedOutcome = 3015
67+
case executableCodeUnrecognizedLanguage = 3016
6768

6869
// SDK State Errors
6970
case generateContentResponseNoCandidates = 4000
7071
case generateContentResponseNoText = 4001
7172
case appCheckTokenFetchFailed = 4002
73+
case generateContentResponseEmptyCandidates = 4003
7274

7375
// SDK Debugging
7476
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
@@ -41,9 +41,17 @@ struct InternalPart: Equatable, Sendable {
4141
case functionResponse(FunctionResponse)
4242
case executableCode(ExecutableCode)
4343
case codeExecutionResult(CodeExecutionResult)
44+
45+
struct UnsupportedDataError: Error {
46+
let decodingError: DecodingError
47+
48+
var localizedDescription: String {
49+
decodingError.localizedDescription
50+
}
51+
}
4452
}
4553

46-
let data: OneOfData
54+
let data: OneOfData?
4755

4856
let isThought: Bool?
4957

@@ -67,7 +75,7 @@ public struct ModelContent: Equatable, Sendable {
6775

6876
/// The data parts comprising this ``ModelContent`` value.
6977
public var parts: [any Part] {
70-
return internalParts.map { part -> any Part in
78+
return internalParts.compactMap { part -> (any Part)? in
7179
switch part.data {
7280
case let .text(text):
7381
return TextPart(text, isThought: part.isThought, thoughtSignature: part.thoughtSignature)
@@ -97,6 +105,9 @@ public struct ModelContent: Equatable, Sendable {
97105
isThought: part.isThought,
98106
thoughtSignature: part.thoughtSignature
99107
)
108+
case .none:
109+
// Filter out parts that contain missing or unrecognized data
110+
return nil
100111
}
101112
}
102113
}
@@ -191,7 +202,14 @@ extension InternalPart: Codable {
191202
}
192203

193204
public init(from decoder: Decoder) throws {
194-
data = try OneOfData(from: decoder)
205+
do {
206+
data = try OneOfData(from: decoder)
207+
} catch let error as OneOfData.UnsupportedDataError {
208+
AILog.error(code: .decodedUnsupportedPartData, error.localizedDescription)
209+
data = nil
210+
} catch { // Re-throw any other error types
211+
throw error
212+
}
195213
let container = try decoder.container(keyedBy: CodingKeys.self)
196214
isThought = try container.decodeIfPresent(Bool.self, forKey: .isThought)
197215
thoughtSignature = try container.decodeIfPresent(String.self, forKey: .thoughtSignature)
@@ -250,9 +268,11 @@ extension InternalPart.OneOfData: Codable {
250268
)
251269
} else {
252270
let unexpectedKeys = values.allKeys.map { $0.stringValue }
253-
throw DecodingError.dataCorrupted(DecodingError.Context(
254-
codingPath: values.codingPath,
255-
debugDescription: "Unexpected Part type(s): \(unexpectedKeys)"
271+
throw UnsupportedDataError(decodingError: DecodingError.dataCorrupted(
272+
DecodingError.Context(
273+
codingPath: values.codingPath,
274+
debugDescription: "Unexpected Part type(s): \(unexpectedKeys)"
275+
)
256276
))
257277
}
258278
}
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_FlashLite = "gemini-2.5-flash-lite"
2930
public static let gemini2_5_Pro = "gemini-2.5-pro"

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
)
@@ -511,6 +517,73 @@ struct GenerateContentIntegrationTests {
511517
#expect(response == expectedResponse)
512518
}
513519

520+
@Test(arguments: [
521+
(InstanceConfig.vertexAI_v1beta, ModelNames.gemini2FlashPreviewImageGeneration),
522+
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2FlashPreviewImageGeneration),
523+
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2_5_FlashImagePreview),
524+
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2FlashPreviewImageGeneration),
525+
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2_5_FlashImagePreview),
526+
// Note: The following configs are commented out for easy one-off manual testing.
527+
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2FlashPreviewImageGeneration)
528+
// (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemini2FlashPreviewImageGeneration),
529+
// (
530+
// InstanceConfig.googleAI_v1beta_freeTier_bypassProxy,
531+
// ModelNames.gemini2FlashPreviewImageGeneration
532+
// ),
533+
])
534+
func generateImageStreaming(_ config: InstanceConfig, modelName: String) async throws {
535+
let generationConfig = GenerationConfig(
536+
temperature: 0.0,
537+
topP: 0.0,
538+
topK: 1,
539+
responseModalities: [.text, .image]
540+
)
541+
let safetySettings = safetySettings.filter {
542+
// HARM_CATEGORY_CIVIC_INTEGRITY is deprecated in Vertex AI but only rejected when using the
543+
// 'gemini-2.0-flash-preview-image-generation' model.
544+
$0.harmCategory != .civicIntegrity
545+
}
546+
let model = FirebaseAI.componentInstance(config).generativeModel(
547+
modelName: modelName,
548+
generationConfig: generationConfig,
549+
safetySettings: safetySettings
550+
)
551+
let prompt = "Generate an image of a cute cartoon kitten playing with a ball of yarn"
552+
553+
let stream = try model.generateContentStream(prompt)
554+
555+
var inlineDataParts = [InlineDataPart]()
556+
for try await response in stream {
557+
let candidate = try #require(response.candidates.first)
558+
let inlineDataPart = candidate.content.parts.first { $0 is InlineDataPart } as? InlineDataPart
559+
if let inlineDataPart {
560+
inlineDataParts.append(inlineDataPart)
561+
let inlineDataPartsViaAccessor = response.inlineDataParts
562+
#expect(inlineDataPartsViaAccessor.count == 1)
563+
#expect(inlineDataPartsViaAccessor == response.inlineDataParts)
564+
}
565+
let textPart = candidate.content.parts.first { $0 is TextPart } as? TextPart
566+
#expect(
567+
inlineDataPart != nil || textPart != nil || candidate.finishReason == .stop,
568+
"No text or image found in the candidate"
569+
)
570+
}
571+
572+
#expect(inlineDataParts.count == 1)
573+
let inlineDataPart = try #require(inlineDataParts.first)
574+
#expect(inlineDataPart.mimeType == "image/png")
575+
#expect(inlineDataPart.data.count > 0)
576+
#if canImport(UIKit)
577+
let uiImage = try #require(UIImage(data: inlineDataPart.data))
578+
// Gemini 2.0 Flash Experimental returns images sized to fit within a 1024x1024 pixel box but
579+
// dimensions may vary depending on the aspect ratio.
580+
#expect(uiImage.size.width <= 1024)
581+
#expect(uiImage.size.width >= 500)
582+
#expect(uiImage.size.height <= 1024)
583+
#expect(uiImage.size.height >= 500)
584+
#endif // canImport(UIKit)
585+
}
586+
514587
// MARK: - App Check Tests
515588

516589
@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)