Skip to content

Commit 1599cd3

Browse files
authored
[Vertex AI] Add ImagenImagesBlockedError (#14456)
1 parent c5bf1e5 commit 1599cd3

File tree

6 files changed

+129
-36
lines changed

6 files changed

+129
-36
lines changed
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
import Foundation
16+
17+
extension Constants {
18+
enum Imagen {}
19+
}
20+
21+
extension Constants.Imagen {
22+
static let errorDomain = "\(Constants.baseErrorDomain).Imagen"
23+
24+
enum ErrorCode: Int {
25+
case imagesBlocked = 1000
26+
}
27+
}

FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenGenerationResponse.swift

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,11 @@ extension ImagenGenerationResponse: Decodable where T: Decodable {
6060
images.append(image)
6161
} else if let filteredReason = try? predictionsContainer.decode(RAIFilteredReason.self) {
6262
filteredReasons.append(filteredReason.raiFilteredReason)
63-
} else if let _ = try? predictionsContainer.decode(JSONObject.self) {
64-
// TODO(#14221): Log unsupported prediction type message with the decoded `JSONObject`.
63+
} else if let unsupportedPrediction = try? predictionsContainer.decode(JSONObject.self) {
64+
VertexLog.warning(
65+
code: .decodedUnsupportedImagenPredictionType,
66+
"Ignoring unsupported Imagen prediction: \(unsupportedPrediction)"
67+
)
6568
} else {
6669
// This should never be thrown since JSONObject accepts any valid JSON.
6770
throw DecodingError.dataCorruptedError(
@@ -73,11 +76,17 @@ extension ImagenGenerationResponse: Decodable where T: Decodable {
7376

7477
self.images = images
7578
let filteredReason = filteredReasons.joined(separator: "\n")
76-
if filteredReason.isEmpty {
77-
self.filteredReason = nil
78-
} else {
79-
self.filteredReason = filteredReason
79+
self.filteredReason = filteredReason.isEmpty ? nil : filteredReason
80+
81+
guard !images.isEmpty || !filteredReasons.isEmpty else {
82+
throw DecodingError.dataCorruptedError(
83+
forKey: .predictions,
84+
in: container,
85+
debugDescription: "No images or filtered reasons in response."
86+
)
87+
}
88+
guard !images.isEmpty else {
89+
throw ImagenImagesBlockedError(message: filteredReason)
8090
}
81-
// TODO(#14221): Throw `ImagenImagesBlockedError` with `filteredReason` if `images` is empty.
8291
}
8392
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
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+
import Foundation
16+
17+
/// An error that occurs when image generation fails due to all generated images being blocked.
18+
///
19+
/// The images may have been blocked due to the specified ``ImagenSafetyFilterLevel``, the
20+
/// ``ImagenPersonFilterLevel``, or filtering included in the model. These filter levels may be
21+
/// adjusted in your ``ImagenSafetySettings``. See the [Responsible AI and usage guidelines for
22+
/// Imagen](https://cloud.google.com/vertex-ai/generative-ai/docs/image/responsible-ai-imagen)
23+
/// for more details.
24+
public struct ImagenImagesBlockedError: Error {
25+
/// The reason that all generated images were blocked (filtered out).
26+
let message: String
27+
}
28+
29+
// MARK: - CustomNSError Conformance
30+
31+
extension ImagenImagesBlockedError: CustomNSError {
32+
public static var errorDomain: String {
33+
return Constants.Imagen.errorDomain
34+
}
35+
36+
public var errorCode: Int {
37+
return Constants.Imagen.ErrorCode.imagesBlocked.rawValue
38+
}
39+
40+
public var errorUserInfo: [String: Any] {
41+
return [NSLocalizedDescriptionKey: message]
42+
}
43+
}

FirebaseVertexAI/Sources/VertexLog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ enum VertexLog {
5858
case decodedInvalidProtoDateDay = 3010
5959
case decodedInvalidCitationPublicationDate = 3011
6060
case generateContentResponseUnrecognizedContentModality = 3012
61+
case decodedUnsupportedImagenPredictionType = 3013
6162

6263
// SDK State Errors
6364
case generateContentResponseNoCandidates = 4000

FirebaseVertexAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -130,16 +130,17 @@ struct ImagenIntegrationTests {
130130
)
131131
let imagePrompt = "A woman, 35mm portrait, in front of a mountain range"
132132

133-
let response = try await model.generateImages(prompt: imagePrompt)
134-
135-
#expect(response.images.isEmpty)
136-
let filteredReason = try #require(response.filteredReason)
137-
// 39322892: Detects a person or face when it isn't allowed due to the request safety settings.
138-
#expect(filteredReason.contains("39322892"))
139-
// TODO(#14221): Update implementation and test to throw an exception when all filtered out.
133+
await #expect {
134+
try await model.generateImages(prompt: imagePrompt)
135+
} throws: {
136+
let error = try #require($0 as? ImagenImagesBlockedError)
137+
#expect(error.errorCode == 1000) // Constants.Imagen.ErrorCode.imagesBlocked
138+
// 39322892: Detected a person or face when it isn't allowed due to request safety settings.
139+
return error.localizedDescription.contains("39322892")
140+
}
140141
}
141142

142143
// TODO(#14221): Add an integration test for the prompt being blocked.
143144

144-
// TODO(#14221): Add integration tests for validating that Storage Rules are enforced.
145+
// TODO(#14452): Add integration tests for validating that Storage Rules are enforced.
145146
}

FirebaseVertexAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,17 @@ final class ImagenGenerationResponseTests: XCTestCase {
168168
"""
169169
let jsonData = try XCTUnwrap(json.data(using: .utf8))
170170

171-
let response = try decoder.decode(
172-
ImagenGenerationResponse<ImagenInlineImage>.self,
173-
from: jsonData
174-
)
175-
176-
XCTAssertEqual(response.images, [])
177-
XCTAssertEqual(response.filteredReason, raiFilteredReason)
171+
do {
172+
let response = try decoder.decode(
173+
ImagenGenerationResponse<ImagenGCSImage>.self,
174+
from: jsonData
175+
)
176+
XCTFail("Expected a ImagenImagesBlockedError, got response: \(response)")
177+
} catch let error as ImagenImagesBlockedError {
178+
XCTAssertEqual(error.message, raiFilteredReason)
179+
} catch {
180+
XCTFail("Expected an ImagenImagesBlockedError, got error: \(error)")
181+
}
178182
}
179183

180184
func testDecodeResponse_noImagesAnd_noFilteredReason() throws {
@@ -211,13 +215,17 @@ final class ImagenGenerationResponseTests: XCTestCase {
211215
"""
212216
let jsonData = try XCTUnwrap(json.data(using: .utf8))
213217

214-
let response = try decoder.decode(
215-
ImagenGenerationResponse<ImagenGCSImage>.self,
216-
from: jsonData
217-
)
218-
219-
XCTAssertEqual(response.images, [])
220-
XCTAssertEqual(response.filteredReason, "\(raiFilteredReason1)\n\(raiFilteredReason2)")
218+
do {
219+
let response = try decoder.decode(
220+
ImagenGenerationResponse<ImagenGCSImage>.self,
221+
from: jsonData
222+
)
223+
XCTFail("Expected an ImagenImagesBlockedError, got response: \(response)")
224+
} catch let error as ImagenImagesBlockedError {
225+
XCTAssertEqual(error.message, "\(raiFilteredReason1)\n\(raiFilteredReason2)")
226+
} catch {
227+
XCTFail("Expected an ImagenImagesBlockedError, got error: \(error)")
228+
}
221229
}
222230

223231
func testDecodeResponse_unknownPrediction() throws {
@@ -232,12 +240,16 @@ final class ImagenGenerationResponseTests: XCTestCase {
232240
"""
233241
let jsonData = try XCTUnwrap(json.data(using: .utf8))
234242

235-
let response = try decoder.decode(
236-
ImagenGenerationResponse<ImagenInlineImage>.self,
237-
from: jsonData
238-
)
239-
240-
XCTAssertEqual(response.images, [])
241-
XCTAssertNil(response.filteredReason)
243+
do {
244+
let response = try decoder.decode(
245+
ImagenGenerationResponse<ImagenGCSImage>.self,
246+
from: jsonData
247+
)
248+
XCTFail("Expected a DecodingError.dataCorrupted, got response: \(response)")
249+
} catch let DecodingError.dataCorrupted(context) {
250+
XCTAssertEqual(context.debugDescription, "No images or filtered reasons in response.")
251+
} catch {
252+
XCTFail("Expected a DecodingError.dataCorrupted, got error: \(error)")
253+
}
242254
}
243255
}

0 commit comments

Comments
 (0)