Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions FirebaseAI/Sources/GenerateContentResponse.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,16 @@ public struct FinishReason: DecodableProtoEnum, Hashable, Sendable {
case prohibitedContent = "PROHIBITED_CONTENT"
case spii = "SPII"
case malformedFunctionCall = "MALFORMED_FUNCTION_CALL"
case imageSafety = "IMAGE_SAFETY"
case imageProhibitedContent = "IMAGE_PROHIBITED_CONTENT"
case imageOther = "IMAGE_OTHER"
case noImage = "NO_IMAGE"
case imageRecitation = "IMAGE_RECITATION"
case language = "LANGUAGE"
case unexpectedToolCall = "UNEXPECTED_TOOL_CALL"
case tooManyToolCalls = "TOO_MANY_TOOL_CALLS"
case missingThoughtSignature = "MISSING_THOUGHT_SIGNATURE"
case malformedResponse = "MALFORMED_RESPONSE"
}

/// Natural stop point of the model or provided stop sequence.
Expand Down Expand Up @@ -285,6 +295,36 @@ public struct FinishReason: DecodableProtoEnum, Hashable, Sendable {
/// Token generation was stopped because the function call generated by the model was invalid.
public static let malformedFunctionCall = FinishReason(kind: .malformedFunctionCall)

/// Token generation stopped because generated images contain safety violations.
public static let imageSafety = FinishReason(kind: .imageSafety)

/// Image generation stopped because generated images have other prohibited content.
public static let imageProhibitedContent = FinishReason(kind: .imageProhibitedContent)

/// Image generation stopped because of other miscellaneous issue.
public static let imageOther = FinishReason(kind: .imageOther)

/// The model was expected to generate an image, but none was generated.
public static let noImage = FinishReason(kind: .noImage)

/// Image generation stopped due to recitation.
public static let imageRecitation = FinishReason(kind: .imageRecitation)

/// The response candidate content was flagged for using an unsupported language.
public static let language = FinishReason(kind: .language)

/// Model generated a tool call but no tools were enabled in the request.
public static let unexpectedToolCall = FinishReason(kind: .unexpectedToolCall)

/// Model called too many tools consecutively, thus the system exited execution.
public static let tooManyToolCalls = FinishReason(kind: .tooManyToolCalls)

/// Request has at least one thought signature missing.
public static let missingThoughtSignature = FinishReason(kind: .missingThoughtSignature)

/// Finished due to malformed response.
public static let malformedResponse = FinishReason(kind: .malformedResponse)

/// Returns the raw string representation of the `FinishReason` value.
///
/// > Note: This value directly corresponds to the values in the [REST
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,44 @@ struct GenerateContentIntegrationTests {
#endif // canImport(UIKit)
}

@Test(arguments: [
(InstanceConfig.vertexAI_v1beta, ModelNames.gemini2_5_FlashImage),
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2_5_FlashImage),
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2_5_FlashImage),
(InstanceConfig.googleAI_v1beta, ModelNames.gemini3_1_FlashImagePreview),
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini3_1_FlashImagePreview),
])
func generateContent_finishReason_imageSafety(_ config: InstanceConfig,
modelName: String) async throws {
let generationConfig = GenerationConfig(
responseModalities: [.image]
)
let model = FirebaseAI.componentInstance(config).generativeModel(
modelName: modelName,
generationConfig: generationConfig,
)
let prompt = "A graphic image of violence" // This prompt should trigger safety violation

do {
let response = try await model.generateContent(prompt)

// vertexAI gemini3_1_FlashImagePreview doesn't throw.
let candidate = try #require(response.candidates.first)
#expect(candidate.finishReason == .stop)
} catch {
guard let error = error as? GenerateContentError else {
Issue.record("Expected a \(GenerateContentError.self); got \(error.self).")
throw error
}
guard case let .responseStoppedEarly(reason, response) = error else {
Issue.record("Expected a GenerateContentError.responseStoppedEarly; got \(error.self).")
throw error
}
#expect(reason == .imageSafety || reason == .noImage)
#expect(response.candidates.first?.content.parts.isEmpty == true) // Ensure no content
}
}

@Test(arguments: [
(InstanceConfig.vertexAI_v1beta, ModelNames.gemini2_5_FlashImage),
(InstanceConfig.vertexAI_v1beta_global, ModelNames.gemini2_5_FlashImage),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,27 @@ struct ImagenIntegrationTests {
}
}

@Test func generateImage_finishReason_imageSafety() async throws {
let generationConfig = ImagenGenerationConfig(
numberOfImages: 1,
imageFormat: .jpeg()
)
let model = vertex.imagenModel(
modelName: "imagen-3.0-fast-generate-001",
generationConfig: generationConfig,
safetySettings: ImagenSafetySettings(
safetyFilterLevel: .blockLowAndAbove,
personFilterLevel: .blockAll
)
)
let imagePrompt = "A graphic image of violence" // This prompt should trigger safety violation

let response = try await model.generateImages(prompt: imagePrompt)

#expect(response.filteredReason == "SAFETY") // Assuming filteredReason is set
#expect(response.images.isEmpty) // No images should be generated
Comment on lines +186 to +189
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This test appears to have a logical contradiction. It expects the call to model.generateImages(prompt: imagePrompt) to succeed, but then asserts that response.images.isEmpty.

Based on the implementation of ImagenGenerationResponse.init(from:), an ImagenImagesBlockedError is thrown if the images array is empty. Therefore, this test should expect an error to be thrown rather than a successful response.

Please consider refactoring this test to use await #expect { ... } throws: { ... } to correctly handle and verify the expected error, similar to the generateImage_allImagesFilteredOut test.

    await #expect {
      _ = try await model.generateImages(prompt: imagePrompt)
    } throws: { error in
      let imagenError = try #require(error as? ImagenImagesBlockedError)
      #expect(imagenError.message == "SAFETY")
    }

}

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

// TODO(#14452): Add integration tests for validating that Storage Rules are enforced.
Expand Down
84 changes: 84 additions & 0 deletions FirebaseAI/Tests/Unit/APITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,88 @@ final class APITests: XCTestCase {
XCTAssertEqual(cacheDetail?.modality, .text)
XCTAssertEqual(cacheDetail?.tokenCount, 50)
}

func testFinishReason_decoding() throws {
let decoder = JSONDecoder()

// Test LANGUAGE
var json = createResponseJSON(finishReason: "LANGUAGE")
var response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .language)

// Test UNEXPECTED_TOOL_CALL
json = createResponseJSON(finishReason: "UNEXPECTED_TOOL_CALL")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .unexpectedToolCall)

// Test TOO_MANY_TOOL_CALLS
json = createResponseJSON(finishReason: "TOO_MANY_TOOL_CALLS")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .tooManyToolCalls)

// Test MISSING_THOUGHT_SIGNATURE
json = createResponseJSON(finishReason: "MISSING_THOUGHT_SIGNATURE")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .missingThoughtSignature)

// Test MALFORMED_RESPONSE
json = createResponseJSON(finishReason: "MALFORMED_RESPONSE")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .malformedResponse)

// Test IMAGE_SAFETY
json = createResponseJSON(finishReason: "IMAGE_SAFETY")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .imageSafety)

// Test IMAGE_PROHIBITED_CONTENT
json = createResponseJSON(finishReason: "IMAGE_PROHIBITED_CONTENT")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .imageProhibitedContent)

// Test IMAGE_OTHER
json = createResponseJSON(finishReason: "IMAGE_OTHER")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .imageOther)

// Test NO_IMAGE
json = createResponseJSON(finishReason: "NO_IMAGE")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .noImage)

// Test IMAGE_RECITATION
json = createResponseJSON(finishReason: "IMAGE_RECITATION")
response = try decoder.decode(GenerateContentResponse.self, from: json)
XCTAssertEqual(response.candidates.first?.finishReason, .imageRecitation)
}
Comment on lines +237 to +289
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test testFinishReason_decoding contains a lot of repetitive code for each FinishReason case, which makes it verbose and harder to maintain.

To improve readability and maintainability, I suggest refactoring this test to be data-driven. You can create an array of test cases (tuples containing the reason string and the expected FinishReason enum case) and iterate through them in a single loop.

  func testFinishReason_decoding() throws {
    let decoder = JSONDecoder()
    let testCases: [(String, FinishReason)] = [
      ("LANGUAGE", .language),
      ("UNEXPECTED_TOOL_CALL", .unexpectedToolCall),
      ("TOO_MANY_TOOL_CALLS", .tooManyToolCalls),
      ("MISSING_THOUGHT_SIGNATURE", .missingThoughtSignature),
      ("MALFORMED_RESPONSE", .malformedResponse),
      ("IMAGE_SAFETY", .imageSafety),
      ("IMAGE_PROHIBITED_CONTENT", .imageProhibitedContent),
      ("IMAGE_OTHER", .imageOther),
      ("NO_IMAGE", .noImage),
      ("IMAGE_RECITATION", .imageRecitation),
    ]

    for (reasonString, expectedReason) in testCases {
      let json = createResponseJSON(finishReason: reasonString)
      let response = try decoder.decode(GenerateContentResponse.self, from: json)
      XCTAssertEqual(
        response.candidates.first?.finishReason,
        expectedReason,
        "Failed to decode finishReason '\(reasonString)'"
      )
    }
  }


// Helper function to create JSON for GenerateContentResponse with a specific finish reason
private func createResponseJSON(finishReason: String) -> Data {
return """
{
"candidates": [
{
"content": {
"parts": [
{ "text": "Test reply" }
],
"role": "model"
},
"finishReason": "\(finishReason)",
"index": 0,
"safetyRatings": []
}
],
"usageMetadata": {
"promptTokenCount": 10,
"cachedContentTokenCount": 0,
"candidatesTokenCount": 5,
"totalTokenCount": 15,
"promptTokensDetails": [],
"cacheTokensDetails": [],
"candidatesTokensDetails": []
}
}
""".data(using: .utf8)!
}
}
Loading