Skip to content

Commit ae3c030

Browse files
daymxnandrewheard
andauthored
fix(ai): migrate gemma 3 models to gemma 4 (#16093)
Co-authored-by: Andrew Heard <andrewheard@google.com>
1 parent dbe3b1b commit ae3c030

2 files changed

Lines changed: 49 additions & 24 deletions

File tree

FirebaseAI/Tests/TestApp/Sources/Constants.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,5 @@ public enum ModelNames {
2828
public static let gemini2_5_FlashLivePreview = "gemini-2.5-flash-native-audio-preview-12-2025"
2929
public static let gemini2_5_Pro = "gemini-2.5-pro"
3030
public static let gemini3_1_FlashLitePreview = "gemini-3.1-flash-lite-preview"
31-
public static let gemma3_4B = "gemma-3-4b-it"
31+
public static let gemma4_31B = "gemma-4-31b-it"
3232
}

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

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,14 @@ struct GenerateContentIntegrationTests {
5353
(InstanceConfig.vertexAI_v1beta_global_appCheckLimitedUse, ModelNames.gemini2_5_FlashLite),
5454
(InstanceConfig.googleAI_v1beta, ModelNames.gemini3_1_FlashLitePreview),
5555
(InstanceConfig.googleAI_v1beta_appCheckLimitedUse, ModelNames.gemini3_1_FlashLitePreview),
56-
(InstanceConfig.googleAI_v1beta, ModelNames.gemma3_4B),
57-
(InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemma3_4B),
56+
(InstanceConfig.googleAI_v1beta, ModelNames.gemma4_31B),
57+
(InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemma4_31B),
5858
// Note: The following configs are commented out for easy one-off manual testing.
5959
// (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemini2_5_FlashLite),
6060
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2_5_FlashLite),
61-
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemma3_4B),
61+
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemma4_31B),
6262
// (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemini2_5_FlashLite),
63-
// (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemma3_4B),
63+
// (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemma4_31B),
6464
])
6565
func generateContent(_ config: InstanceConfig, modelName: String) async throws {
6666
let model = FirebaseAI.componentInstance(config).generativeModel(
@@ -82,15 +82,13 @@ struct GenerateContentIntegrationTests {
8282
#expect(promptTokensDetails.modality == .text)
8383
#expect(promptTokensDetails.tokenCount == usageMetadata.promptTokenCount)
8484
// No thoughts in Flash Lite.
85-
#expect(usageMetadata.thoughtsTokenCount == 0)
86-
// The fields `candidatesTokenCount` and `candidatesTokensDetails` are not included when using
87-
// Gemma models.
88-
if modelName.hasPrefix("gemini-3") {
85+
if !modelName.contains("flash-lite") {
86+
#expect(usageMetadata.thoughtsTokenCount > 0)
87+
}
88+
// The `candidatesTokensDetails` field is not included when using Gemini 3 or Gemma models.
89+
if modelName.hasPrefix("gemini-3") || modelName.hasPrefix("gemma") {
8990
#expect(usageMetadata.candidatesTokenCount == 2)
9091
#expect(usageMetadata.candidatesTokensDetails.isEmpty)
91-
} else if modelName.hasPrefix("gemma") {
92-
#expect(usageMetadata.candidatesTokenCount == 0)
93-
#expect(usageMetadata.candidatesTokensDetails.isEmpty)
9492
} else {
9593
#expect(usageMetadata.candidatesTokenCount.isEqual(to: 3, accuracy: tokenCountAccuracy))
9694
#expect(usageMetadata.candidatesTokensDetails.count == 1)
@@ -528,15 +526,15 @@ struct GenerateContentIntegrationTests {
528526
),
529527
(InstanceConfig.googleAI_v1beta, ModelNames.gemini2_5_FlashLite),
530528
(InstanceConfig.googleAI_v1beta_appCheckLimitedUse, ModelNames.gemini2_5_FlashLite),
531-
(InstanceConfig.googleAI_v1beta, ModelNames.gemma3_4B),
529+
(InstanceConfig.googleAI_v1beta, ModelNames.gemma4_31B),
532530
// Note: The following configs are commented out for easy one-off manual testing.
533531
// (InstanceConfig.vertexAI_v1beta_staging, ModelNames.gemini2_5_FlashLite),
534532
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemini2_5_FlashLite),
535-
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemma3_4B),
533+
// (InstanceConfig.googleAI_v1beta_staging, ModelNames.gemma4_31B),
536534
// (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemini2_5_FlashLite),
537-
// (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemma3_4B),
535+
// (InstanceConfig.googleAI_v1beta_freeTier_bypassProxy, ModelNames.gemma4_31B),
538536
// (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemini2_5_FlashLite),
539-
// (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemma3_4B),
537+
// (InstanceConfig.googleAI_v1beta_freeTier, ModelNames.gemma4_31B),
540538
])
541539
func generateContentStream(_ config: InstanceConfig, modelName: String) async throws {
542540
let expectedResponse = [
@@ -566,27 +564,41 @@ struct GenerateContentIntegrationTests {
566564
textValues.append(text)
567565
} else if let finishReason = value.candidates.first?.finishReason {
568566
#expect(finishReason == .stop)
567+
} else if let thoughtSummary = value.thoughtSummary {
568+
#expect(!thoughtSummary.isEmpty)
569569
} else {
570570
Issue.record("Expected a candidate with a `TextPart` or a `finishReason`; got \(value).")
571571
}
572572
}
573573

574+
// Tests the text derived from streaming directly
575+
let modelJSONData = try #require(textValues.joined().data(using: .utf8))
576+
let response = try JSONDecoder().decode([String].self, from: modelJSONData)
577+
#expect(response == expectedResponse)
578+
574579
let userHistory = try #require(chat.history.first)
575580
#expect(userHistory.role == "user")
576581
#expect(userHistory.parts.count == 1)
577582
let promptTextPart = try #require(userHistory.parts.first as? TextPart)
578583
#expect(promptTextPart.text == prompt)
579584
let modelHistory = try #require(chat.history.last)
580585
#expect(modelHistory.role == "model")
581-
if modelName.hasPrefix("gemini-3.1-") {
582-
#expect(modelHistory.parts.count == 2)
583-
} else {
584-
#expect(modelHistory.parts.count == 1)
586+
let textParts = modelHistory.parts.compactMap { $0 as? TextPart }.filter {
587+
!$0.isThoughtOrRelated()
585588
}
586-
let modelTextPart = try #require(modelHistory.parts.first as? TextPart)
587-
let modelJSONData = try #require(modelTextPart.text.data(using: .utf8))
588-
let response = try JSONDecoder().decode([String].self, from: modelJSONData)
589-
#expect(response == expectedResponse)
589+
if textParts.count > 1 {
590+
Issue.record("Found multiple text parts: \(textParts)")
591+
}
592+
#expect(
593+
textParts.count == 1,
594+
"The model should reply with exactly one (non thought) text response."
595+
)
596+
597+
// Tests the text derived from the chat history
598+
let historyTextPart = try #require(textParts.first)
599+
let historyModelJSONData = try #require(historyTextPart.text.data(using: .utf8))
600+
let historyResponse = try JSONDecoder().decode([String].self, from: historyModelJSONData)
601+
#expect(historyResponse == expectedResponse)
590602
}
591603

592604
@Test(arguments: [
@@ -665,3 +677,16 @@ struct GenerateContentIntegrationTests {
665677
}
666678
}
667679
}
680+
681+
extension TextPart {
682+
/// Whether this text part is a thought or thought related text part.
683+
///
684+
/// In such cases, it can be ignored for display and testing purposes.
685+
///
686+
/// We use this over just a standard `isThought` check so that we can
687+
/// catch cases where the gemini model sends a text part with empty text that just
688+
/// acts as the last thought of the model.
689+
func isThoughtOrRelated() -> Bool {
690+
return isThought || (thoughtSignature != nil && text.isEmpty)
691+
}
692+
}

0 commit comments

Comments
 (0)