diff --git a/FirebaseVertexAI/Sources/Types/Internal/Imagen/ImagenConstants.swift b/FirebaseVertexAI/Sources/Types/Internal/Imagen/ImagenConstants.swift new file mode 100644 index 00000000000..685f5104263 --- /dev/null +++ b/FirebaseVertexAI/Sources/Types/Internal/Imagen/ImagenConstants.swift @@ -0,0 +1,27 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +extension Constants { + enum Imagen {} +} + +extension Constants.Imagen { + static let errorDomain = "\(Constants.baseErrorDomain).Imagen" + + enum ErrorCode: Int { + case imagesBlocked = 1000 + } +} diff --git a/FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenGenerationResponse.swift b/FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenGenerationResponse.swift index de0f0c06c38..b2188a053c7 100644 --- a/FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenGenerationResponse.swift +++ b/FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenGenerationResponse.swift @@ -60,8 +60,11 @@ extension ImagenGenerationResponse: Decodable where T: Decodable { images.append(image) } else if let filteredReason = try? predictionsContainer.decode(RAIFilteredReason.self) { filteredReasons.append(filteredReason.raiFilteredReason) - } else if let _ = try? predictionsContainer.decode(JSONObject.self) { - // TODO(#14221): Log unsupported prediction type message with the decoded `JSONObject`. + } else if let unsupportedPrediction = try? predictionsContainer.decode(JSONObject.self) { + VertexLog.warning( + code: .decodedUnsupportedImagenPredictionType, + "Ignoring unsupported Imagen prediction: \(unsupportedPrediction)" + ) } else { // This should never be thrown since JSONObject accepts any valid JSON. throw DecodingError.dataCorruptedError( @@ -73,11 +76,17 @@ extension ImagenGenerationResponse: Decodable where T: Decodable { self.images = images let filteredReason = filteredReasons.joined(separator: "\n") - if filteredReason.isEmpty { - self.filteredReason = nil - } else { - self.filteredReason = filteredReason + self.filteredReason = filteredReason.isEmpty ? nil : filteredReason + + guard !images.isEmpty || !filteredReasons.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .predictions, + in: container, + debugDescription: "No images or filtered reasons in response." + ) + } + guard !images.isEmpty else { + throw ImagenImagesBlockedError(message: filteredReason) } - // TODO(#14221): Throw `ImagenImagesBlockedError` with `filteredReason` if `images` is empty. } } diff --git a/FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenImagesBlockedError.swift b/FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenImagesBlockedError.swift new file mode 100644 index 00000000000..e23beab5248 --- /dev/null +++ b/FirebaseVertexAI/Sources/Types/Public/Imagen/ImagenImagesBlockedError.swift @@ -0,0 +1,43 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +/// An error that occurs when image generation fails due to all generated images being blocked. +/// +/// The images may have been blocked due to the specified ``ImagenSafetyFilterLevel``, the +/// ``ImagenPersonFilterLevel``, or filtering included in the model. These filter levels may be +/// adjusted in your ``ImagenSafetySettings``. See the [Responsible AI and usage guidelines for +/// Imagen](https://cloud.google.com/vertex-ai/generative-ai/docs/image/responsible-ai-imagen) +/// for more details. +public struct ImagenImagesBlockedError: Error { + /// The reason that all generated images were blocked (filtered out). + let message: String +} + +// MARK: - CustomNSError Conformance + +extension ImagenImagesBlockedError: CustomNSError { + public static var errorDomain: String { + return Constants.Imagen.errorDomain + } + + public var errorCode: Int { + return Constants.Imagen.ErrorCode.imagesBlocked.rawValue + } + + public var errorUserInfo: [String: Any] { + return [NSLocalizedDescriptionKey: message] + } +} diff --git a/FirebaseVertexAI/Sources/VertexLog.swift b/FirebaseVertexAI/Sources/VertexLog.swift index 792d13358f6..9332aa87961 100644 --- a/FirebaseVertexAI/Sources/VertexLog.swift +++ b/FirebaseVertexAI/Sources/VertexLog.swift @@ -58,6 +58,7 @@ enum VertexLog { case decodedInvalidProtoDateDay = 3010 case decodedInvalidCitationPublicationDate = 3011 case generateContentResponseUnrecognizedContentModality = 3012 + case decodedUnsupportedImagenPredictionType = 3013 // SDK State Errors case generateContentResponseNoCandidates = 4000 diff --git a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift index fc07ff8acef..5b2587743d4 100644 --- a/FirebaseVertexAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift +++ b/FirebaseVertexAI/Tests/TestApp/Tests/Integration/ImagenIntegrationTests.swift @@ -130,16 +130,17 @@ struct ImagenIntegrationTests { ) let imagePrompt = "A woman, 35mm portrait, in front of a mountain range" - let response = try await model.generateImages(prompt: imagePrompt) - - #expect(response.images.isEmpty) - let filteredReason = try #require(response.filteredReason) - // 39322892: Detects a person or face when it isn't allowed due to the request safety settings. - #expect(filteredReason.contains("39322892")) - // TODO(#14221): Update implementation and test to throw an exception when all filtered out. + await #expect { + try await model.generateImages(prompt: imagePrompt) + } throws: { + let error = try #require($0 as? ImagenImagesBlockedError) + #expect(error.errorCode == 1000) // Constants.Imagen.ErrorCode.imagesBlocked + // 39322892: Detected a person or face when it isn't allowed due to request safety settings. + return error.localizedDescription.contains("39322892") + } } // TODO(#14221): Add an integration test for the prompt being blocked. - // TODO(#14221): Add integration tests for validating that Storage Rules are enforced. + // TODO(#14452): Add integration tests for validating that Storage Rules are enforced. } diff --git a/FirebaseVertexAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift b/FirebaseVertexAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift index e52db094028..aa5ebc21bb7 100644 --- a/FirebaseVertexAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift +++ b/FirebaseVertexAI/Tests/Unit/Types/Imagen/ImagenGenerationResponseTests.swift @@ -168,13 +168,17 @@ final class ImagenGenerationResponseTests: XCTestCase { """ let jsonData = try XCTUnwrap(json.data(using: .utf8)) - let response = try decoder.decode( - ImagenGenerationResponse.self, - from: jsonData - ) - - XCTAssertEqual(response.images, []) - XCTAssertEqual(response.filteredReason, raiFilteredReason) + do { + let response = try decoder.decode( + ImagenGenerationResponse.self, + from: jsonData + ) + XCTFail("Expected a ImagenImagesBlockedError, got response: \(response)") + } catch let error as ImagenImagesBlockedError { + XCTAssertEqual(error.message, raiFilteredReason) + } catch { + XCTFail("Expected an ImagenImagesBlockedError, got error: \(error)") + } } func testDecodeResponse_noImagesAnd_noFilteredReason() throws { @@ -211,13 +215,17 @@ final class ImagenGenerationResponseTests: XCTestCase { """ let jsonData = try XCTUnwrap(json.data(using: .utf8)) - let response = try decoder.decode( - ImagenGenerationResponse.self, - from: jsonData - ) - - XCTAssertEqual(response.images, []) - XCTAssertEqual(response.filteredReason, "\(raiFilteredReason1)\n\(raiFilteredReason2)") + do { + let response = try decoder.decode( + ImagenGenerationResponse.self, + from: jsonData + ) + XCTFail("Expected an ImagenImagesBlockedError, got response: \(response)") + } catch let error as ImagenImagesBlockedError { + XCTAssertEqual(error.message, "\(raiFilteredReason1)\n\(raiFilteredReason2)") + } catch { + XCTFail("Expected an ImagenImagesBlockedError, got error: \(error)") + } } func testDecodeResponse_unknownPrediction() throws { @@ -232,12 +240,16 @@ final class ImagenGenerationResponseTests: XCTestCase { """ let jsonData = try XCTUnwrap(json.data(using: .utf8)) - let response = try decoder.decode( - ImagenGenerationResponse.self, - from: jsonData - ) - - XCTAssertEqual(response.images, []) - XCTAssertNil(response.filteredReason) + do { + let response = try decoder.decode( + ImagenGenerationResponse.self, + from: jsonData + ) + XCTFail("Expected a DecodingError.dataCorrupted, got response: \(response)") + } catch let DecodingError.dataCorrupted(context) { + XCTAssertEqual(context.debugDescription, "No images or filtered reasons in response.") + } catch { + XCTFail("Expected a DecodingError.dataCorrupted, got error: \(error)") + } } }