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
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,4 @@ firebase-appdistribution-gradle/service-credentials.json
gha-creds-*.json

# Jetski CLI artifacts
.jetskicli/
.jetskicli/
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,18 @@ internal class SchemaSymbolProcessorVisitor(
builder.addStatement("JsonSchema.double(").indent()
}
"kotlin.String" -> {
builder.addStatement("JsonSchema.string(").indent()
if (!guideValues.enumValues.isNullOrEmpty()) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

enforces the enum values. Without this, we might receive strings not in the enum list.

builder
.addStatement("JsonSchema.enumeration(")
.indent()
.addStatement("values = listOf(")
.indent()
.addStatement(guideValues.enumValues.joinToString { "\"$it\"" })
.unindent()
.addStatement("),")
} else {
builder.addStatement("JsonSchema.string(").indent()
}
}
"kotlin.collections.List" -> {

Expand Down Expand Up @@ -239,12 +250,20 @@ internal class SchemaSymbolProcessorVisitor(
"format is not a valid parameter to specify in @Guide"
)
}
if (!guideValues.enumValues.isNullOrEmpty() && className.canonicalName != "kotlin.String") {
logger.warn(
"${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, " +
"enumValues is not a valid parameter to specify in @Guide"
)
}
guideValues.minimum?.let { builder.addStatement("minimum = %L,", it) }
guideValues.maximum?.let { builder.addStatement("maximum = %L,", it) }
guideValues.minItems?.let { builder.addStatement("minItems = %L,", it) }

guideValues.maxItems?.let { builder.addStatement("maxItems = %L,", it) }
guideValues.format?.let { builder.addStatement("format = %S,", it) }
guideValues.format?.let {
builder.addStatement("format = com.google.firebase.ai.type.StringFormat.Custom(%S),", it)
}
builder.addStatement("nullable = %L)", className.isNullable).unindent()
return builder.build()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ internal data class GuideValues(
val minItems: Int?,
val maxItems: Int?,
val format: String?,
val description: String?
val description: String?,
val enumValues: List<String>?,
)

internal fun getGuideValuesFromAnnotation(
Expand All @@ -45,7 +46,8 @@ internal fun getGuideValuesFromAnnotation(
minItems = getIntFromAnnotation(guideAnnotation, "minItems"),
maxItems = getIntFromAnnotation(guideAnnotation, "maxItems"),
format = getStringFromAnnotation(guideAnnotation, "format"),
description = description
description = description,
enumValues = getStringListFromAnnotation(guideAnnotation, "enumValues")
)

internal fun getDescriptionFromAnnotations(
Expand Down Expand Up @@ -103,6 +105,21 @@ internal fun getStringFromAnnotation(
return guidePropertyStringValue
}

internal fun getStringListFromAnnotation(
guideAnnotation: KSAnnotation?,
listName: String,
): List<String>? {
val guidePropertyStringListValue =
guideAnnotation
?.arguments
?.firstOrNull { it.name?.getShortName()?.equals(listName) == true }
?.value as? List<*>
if (guidePropertyStringListValue.isNullOrEmpty()) {
return null
}
return guidePropertyStringListValue?.filterIsInstance<String>()
}

internal fun extractBaseKdoc(kdoc: String): String? {
return baseKdocRegex.matchEntire(kdoc)?.groups?.get(1)?.value?.trim().let {
if (it.isNullOrEmpty()) null else it
Expand Down
2 changes: 2 additions & 0 deletions ai-logic/firebase-ai/firebase-ai.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ plugins {
id("firebase-library")
id("kotlin-android")
id("copy-google-services")
id("com.google.devtools.ksp") version "2.1.21-2.0.2"
alias(libs.plugins.kotlinx.serialization)
}

Expand Down Expand Up @@ -126,4 +127,5 @@ dependencies {
androidTestImplementation(libs.androidx.test.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.truth)
kspAndroidTest(project(":ai-logic:firebase-ai-ksp-processor"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/*
* Copyright 2026 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.
*/

package com.google.firebase.ai

import androidx.test.ext.junit.runners.AndroidJUnit4
import com.google.firebase.FirebaseApp
import com.google.firebase.ai.annotations.Generable
import com.google.firebase.ai.annotations.Guide
import com.google.firebase.ai.type.GenerativeBackend
import com.google.firebase.ai.type.JsonSchema
import com.google.firebase.ai.type.generationConfig
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@Serializable
enum class RecipeDifficulty {
EASY,
MEDIUM,
HARD
}

@Serializable
@Generable
data class Ingredient(
@Guide(description = "The name of the ingredient") val name: String,
@Guide(description = "The quantity, like '1 cup'") val quantity: String,
@Guide(description = "Optional preparation step") val preparation: String? = null
) {
companion object
}

@Serializable
@Generable
data class ComprehensiveRecipe(
@Guide(description = "The title of the recipe", format = "recipe-title-format") val title: String,
@Guide(description = "The number of minutes to cook", minimum = 5.0, maximum = 120.0)
val cookTimeMinutes: Int,
@Guide(description = "A score from 0.0 to 10.0") val healthScore: Double,
@Guide(description = "Is this recipe vegetarian?") val isVegetarian: Boolean? = null,
@Guide(description = "The difficulty of the recipe") val difficulty: RecipeDifficulty,
@Guide(
description = "Category of the recipe",
enumValues = ["Dessert", "Main Course", "Appetizer", "Beverage"]
)
val category: String,
@Guide(description = "The ingredients needed", minItems = 2, maxItems = 10)
val ingredients: List<Ingredient>
) {
companion object
}

@RunWith(AndroidJUnit4::class)
class CloudStructuredOutputIntegrationTests {

@Before
fun setUp() {
if (
FirebaseApp.getApps(
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext
)
.isEmpty()
) {
FirebaseApp.initializeApp(
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext
)
}
}

private val manualIngredientSchema =
JsonSchema.obj(
properties =
mapOf(
"name" to JsonSchema.string(description = "The name of the ingredient"),
"quantity" to JsonSchema.string(description = "The quantity, like '1 cup'"),
"preparation" to
JsonSchema.string(description = "Optional preparation step", nullable = true)
),
optionalProperties = listOf("preparation"),
clazz = Ingredient::class
)

private val manualRecipeSchema =
JsonSchema.obj(
properties =
mapOf(
"title" to
JsonSchema.string(
description = "The title of the recipe",
format = com.google.firebase.ai.type.StringFormat.Custom("recipe-title-format")
),
"cookTimeMinutes" to
JsonSchema.integer(
description = "The number of minutes to cook",
minimum = 5.0,
maximum = 120.0
),
"healthScore" to JsonSchema.double(description = "A score from 0.0 to 10.0"),
"isVegetarian" to
JsonSchema.boolean(description = "Is this recipe vegetarian?", nullable = true),
"difficulty" to
JsonSchema.enumeration(
values = listOf("EASY", "MEDIUM", "HARD"),
clazz = RecipeDifficulty::class,
description = "The difficulty of the recipe"
),
"category" to
JsonSchema.enumeration(
values = listOf("Dessert", "Main Course", "Appetizer", "Beverage"),
description = "Category of the recipe"
),
"ingredients" to
JsonSchema.array(
items = manualIngredientSchema,
description = "The ingredients needed",
minItems = 2,
maxItems = 10
)
),
optionalProperties = listOf("isVegetarian"),
clazz = ComprehensiveRecipe::class
)

private val jsonFormat = Json { ignoreUnknownKeys = true }

@Test
fun testGenerateContentManualSchema() = runBlocking {
val config = generationConfig {
responseMimeType = "application/json"
responseJsonSchema = manualRecipeSchema
}
val generativeModel =
FirebaseAI.getInstance(FirebaseApp.getInstance(), GenerativeBackend.googleAI())
.generativeModel(modelName = "gemini-3.5-flash", generationConfig = config)
val response = generativeModel.generateContent("Create a recipe for a healthy apple pie")
val jsonString = response.text
assertNotNull(jsonString)
println("========== jsonString: $jsonString")
val recipe = jsonFormat.decodeFromString<ComprehensiveRecipe>(jsonString!!)
println("========== recipe: $recipe")

assertNotNull(recipe.title)
assertTrue(recipe.ingredients.size in 2..10)
assertTrue(recipe.cookTimeMinutes in 5..120)
assertTrue(listOf("Dessert", "Main Course", "Appetizer", "Beverage").contains(recipe.category))
assertNotNull(recipe.difficulty)
recipe.ingredients.forEach {
assertNotNull(it.name)
assertNotNull(it.quantity)
}
// isVegetarian can be null, but we verify it's successfully parsed if present
assertTrue(recipe.isVegetarian == null || recipe.isVegetarian is Boolean)
}

@Test
fun testGenerateObjectManualSchema() = runBlocking {
val generativeModel =
FirebaseAI.getInstance(FirebaseApp.getInstance(), GenerativeBackend.googleAI())
.generativeModel(modelName = "gemini-3.5-flash")

val response =
generativeModel.generateObject<ComprehensiveRecipe>(
manualRecipeSchema,
"Create a recipe for a healthy apple pie"
)
val recipe = response.getObject()
println("========== recipe: $recipe")

assertNotNull(recipe)
assertNotNull(recipe!!.title)
assertTrue(recipe.ingredients.size in 2..10)
assertTrue(recipe.cookTimeMinutes in 5..120)
assertTrue(listOf("Dessert", "Main Course", "Appetizer", "Beverage").contains(recipe.category))
assertNotNull(recipe.difficulty)
recipe.ingredients.forEach {
assertNotNull(it.name)
assertNotNull(it.quantity)
}
assertTrue(recipe.isVegetarian == null || recipe.isVegetarian is Boolean)
}

@Test
fun testGenerateContentKspSchema() = runBlocking {
val config = generationConfig {
responseMimeType = "application/json"
responseJsonSchema = ComprehensiveRecipe.firebaseAISchema()
}
val generativeModel =
FirebaseAI.getInstance(FirebaseApp.getInstance(), GenerativeBackend.googleAI())
.generativeModel(modelName = "gemini-3.5-flash", generationConfig = config)
val response = generativeModel.generateContent("Create a recipe for a healthy apple pie")
val jsonString = response.text
println("========== jsonString: $jsonString")
assertNotNull(jsonString)

val recipe = jsonFormat.decodeFromString<ComprehensiveRecipe>(jsonString!!)
println("========== recipe: $recipe")

assertNotNull(recipe.title)
assertTrue(recipe.ingredients.size in 2..10)
assertTrue(recipe.cookTimeMinutes in 5..120)
assertTrue(listOf("Dessert", "Main Course", "Appetizer", "Beverage").contains(recipe.category))
assertNotNull(recipe.difficulty)
recipe.ingredients.forEach {
assertNotNull(it.name)
assertNotNull(it.quantity)
}
assertTrue(recipe.isVegetarian == null || recipe.isVegetarian is Boolean)
}

@Test
fun testGenerateObjectKspSchema() = runBlocking {
val generativeModel =
FirebaseAI.getInstance(FirebaseApp.getInstance(), GenerativeBackend.googleAI())
.generativeModel(modelName = "gemini-3.5-flash")

val response =
generativeModel.generateObject<ComprehensiveRecipe>(
ComprehensiveRecipe.firebaseAISchema(),
"Create a recipe for a healthy apple pie"
)
val recipe = response.getObject()
println("========== recipe: $recipe")

assertNotNull(recipe)
assertNotNull(recipe!!.title)
assertTrue(recipe.ingredients.size in 2..10)
assertTrue(recipe.cookTimeMinutes in 5..120)
assertTrue(listOf("Dessert", "Main Course", "Appetizer", "Beverage").contains(recipe.category))
assertNotNull(recipe.difficulty)
recipe.ingredients.forEach {
assertNotNull(it.name)
assertNotNull(it.quantity)
}
assertTrue(recipe.isVegetarian == null || recipe.isVegetarian is Boolean)
}
}
Loading
Loading