diff --git a/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorVisitor.kt b/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorVisitor.kt index 9109352dbd6..0f780983662 100644 --- a/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorVisitor.kt +++ b/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorVisitor.kt @@ -25,12 +25,18 @@ import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.symbol.KSVisitorVoid import com.google.devtools.ksp.symbol.Modifier +import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterSpec import com.squareup.kotlinpoet.ParameterizedTypeName import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.toTypeName import com.squareup.kotlinpoet.ksp.writeTo @@ -57,6 +63,11 @@ internal class SchemaSymbolProcessorVisitor( codeGenerator, Dependencies(true, containingFile), ) + val companionFile = generateMlKitCompanionFileSpec(classDeclaration) + companionFile.writeTo( + codeGenerator, + Dependencies(true, containingFile), + ) } fun generateFileSpec(classDeclaration: KSClassDeclaration): FileSpec { @@ -142,7 +153,18 @@ internal class SchemaSymbolProcessorVisitor( builder.addStatement("JsonSchema.double(").indent() } "kotlin.String" -> { - builder.addStatement("JsonSchema.string(").indent() + if (!guideValues.enumValues.isNullOrEmpty()) { + 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" -> { @@ -155,7 +177,12 @@ internal class SchemaSymbolProcessorVisitor( throw RuntimeException() } val listParamCodeBlock = - generateCodeBlockForSchema(type = listTypeParam.resolve(), parentType = type) + generateCodeBlockForSchema( + type = listTypeParam.resolve(), + parentType = type, + guideAnnotation = + if (!guideValues.enumValues.isNullOrEmpty()) guideAnnotation else null, + ) builder .addStatement("JsonSchema.array(") .indent() @@ -248,4 +275,124 @@ internal class SchemaSymbolProcessorVisitor( builder.addStatement("nullable = %L)", className.isNullable).unindent() return builder.build() } + + private fun isGenerableClass(type: KSType): Boolean { + return type.declaration.annotations.any { it.shortName.getShortName() == "Generable" } + } + + private fun isListOfGenerableClass(type: KSType): Boolean { + val qualifiedName = type.declaration.qualifiedName?.asString() + if (qualifiedName == "kotlin.collections.List" || qualifiedName == "java.util.List") { + val argType = type.arguments.firstOrNull()?.type?.resolve() + if (argType != null) { + return isGenerableClass(argType) + } + } + return false + } + + private fun mapToMlKitCompanionType(type: KSType, packageName: String): TypeName { + if (isListOfGenerableClass(type)) { + type.arguments.firstOrNull()?.type?.resolve()?.let { argType -> + val argClassName = + ClassName( + argType.declaration.packageName.asString(), + "${argType.declaration.simpleName.asString()}_MlKitCompanion", + ) + return ClassName("kotlin.collections", "List").parameterizedBy(argClassName) + } + } else if (isGenerableClass(type)) { + return ClassName( + type.declaration.packageName.asString(), + "${type.declaration.simpleName.asString()}_MlKitCompanion" + ) + } + return type.toTypeName() + } + + fun generateMlKitCompanionFileSpec(classDeclaration: KSClassDeclaration): FileSpec { + val packageName = classDeclaration.packageName.asString() + val companionClassName = "${classDeclaration.simpleName.asString()}_MlKitCompanion" + val fileBuilder = + FileSpec.builder(packageName, companionClassName).addAnnotation(Generated::class) + + val classBuilder = TypeSpec.classBuilder(companionClassName).addModifiers(KModifier.DATA) + val keepAnnotation = AnnotationSpec.builder(ClassName("androidx.annotation", "Keep")).build() + classBuilder.addAnnotation(keepAnnotation) + + val generableAnn = + classDeclaration.annotations.firstOrNull { it.shortName.getShortName() == "Generable" } + val classDesc = getStringFromAnnotation(generableAnn, "description") + val mlkitGenerableBuilder = + AnnotationSpec.builder(ClassName("com.google.mlkit.genai.schema.annotations", "Generable")) + if (!classDesc.isNullOrEmpty()) { + mlkitGenerableBuilder.addMember("description = %S", classDesc) + } + classBuilder.addAnnotation(mlkitGenerableBuilder.build()) + + val primaryConstructor = FunSpec.constructorBuilder() + val toSdkBuilder = + FunSpec.builder("toSdk") + .addAnnotation(keepAnnotation) + .returns(ClassName(packageName, classDeclaration.simpleName.asString())) + val toSdkArgs = mutableListOf() + + classDeclaration.getAllProperties().forEach { property -> + val propName = property.simpleName.asString() + val propType = property.type.resolve() + val typeName = mapToMlKitCompanionType(propType, packageName) + + val paramBuilder = ParameterSpec.builder(propName, typeName) + val propBuilder = PropertySpec.builder(propName, typeName).initializer(propName) + + val guideAnn = property.annotations.firstOrNull { it.shortName.getShortName() == "Guide" } + if (guideAnn != null) { + val guideValues = + getGuideValuesFromAnnotation(guideAnn, getStringFromAnnotation(guideAnn, "description")) + val mlkitGuideBuilder = + AnnotationSpec.builder(ClassName("com.google.mlkit.genai.schema.annotations", "Guide")) + if (!guideValues.description.isNullOrEmpty()) + mlkitGuideBuilder.addMember("description = %S", guideValues.description) + if (guideValues.minimum != null) + mlkitGuideBuilder.addMember("minimum = %L", guideValues.minimum) + if (guideValues.maximum != null) + mlkitGuideBuilder.addMember("maximum = %L", guideValues.maximum) + if (guideValues.minItems != null) + mlkitGuideBuilder.addMember("minItems = %L", guideValues.minItems) + if (guideValues.maxItems != null) + mlkitGuideBuilder.addMember("maxItems = %L", guideValues.maxItems) + if (!guideValues.format.isNullOrEmpty()) + mlkitGuideBuilder.addMember("format = %S", guideValues.format) + if (!guideValues.enumValues.isNullOrEmpty()) { + val enumElements = guideValues.enumValues.joinToString { "%S" } + mlkitGuideBuilder.addMember( + "enumValues = arrayOf($enumElements)", + *guideValues.enumValues.toTypedArray() + ) + } + paramBuilder.addAnnotation(mlkitGuideBuilder.build()) + } + + primaryConstructor.addParameter(paramBuilder.build()) + classBuilder.addProperty(propBuilder.build()) + + if (isListOfGenerableClass(propType)) { + toSdkArgs.add("$propName = this.$propName.map { it.toSdk() }") + } else if (isGenerableClass(propType)) { + toSdkArgs.add("$propName = this.$propName.toSdk()") + } else { + toSdkArgs.add("$propName = this.$propName") + } + } + + classBuilder.primaryConstructor(primaryConstructor.build()) + toSdkBuilder.addStatement( + "return %T(\n ${toSdkArgs.joinToString(",\n ")}\n)", + ClassName(packageName, classDeclaration.simpleName.asString()) + ) + classBuilder.addFunction(toSdkBuilder.build()) + + fileBuilder.addType(classBuilder.build()) + return fileBuilder.build() + } } diff --git a/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/Util.kt b/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/Util.kt index 694764e1abf..d5a7a26ca00 100644 --- a/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/Util.kt +++ b/ai-logic/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/Util.kt @@ -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? = null, ) internal fun getGuideValuesFromAnnotation( @@ -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( @@ -103,6 +105,27 @@ internal fun getStringFromAnnotation( return guidePropertyStringValue } +internal fun getStringListFromAnnotation( + guideAnnotation: KSAnnotation?, + listName: String, +): List? { + val rawValue = + guideAnnotation + ?.arguments + ?.firstOrNull { it.name?.getShortName()?.equals(listName) == true } + ?.value + val list = + when (rawValue) { + is List<*> -> rawValue.mapNotNull { it as? String } + is Array<*> -> rawValue.mapNotNull { it as? String } + else -> null + } + if (list.isNullOrEmpty()) { + return null + } + return list +} + internal fun extractBaseKdoc(kdoc: String): String? { return baseKdocRegex.matchEntire(kdoc)?.groups?.get(1)?.value?.trim().let { if (it.isNullOrEmpty()) null else it diff --git a/ai-logic/firebase-ai-ksp-processor/test-app/src/main/kotlin/com/google/firebase/testing/processor/SchemaTest.kt b/ai-logic/firebase-ai-ksp-processor/test-app/src/main/kotlin/com/google/firebase/testing/processor/SchemaTest.kt index c770584e698..531a5a8ef25 100644 --- a/ai-logic/firebase-ai-ksp-processor/test-app/src/main/kotlin/com/google/firebase/testing/processor/SchemaTest.kt +++ b/ai-logic/firebase-ai-ksp-processor/test-app/src/main/kotlin/com/google/firebase/testing/processor/SchemaTest.kt @@ -32,14 +32,17 @@ data class RootSchemaTestClass( val listTest: List, @Guide(description = "most likely true, very rarely false") val booleanTest: Boolean, val stringTest: String, - val objTest: SecondarySchemaTestClass, + val nestedSchemaTest: SecondarySchemaTestClass, val enumTest: EnumTest, + @Guide(enumValues = ["NORTH", "SOUTH", "EAST", "WEST"]) val stringEnumTest: String, ) { companion object } -/** class kdoc should be used if property kdocs aren't present */ -data class SecondarySchemaTestClass(val testInt: Int) +@Generable +data class SecondarySchemaTestClass(val testInt: Int) { + companion object +} enum class EnumTest { A, diff --git a/ai-logic/firebase-ai-ksp-processor/test-app/src/test/kotlin/com/google/firebase/testing/processor/FirebaseKspProcessorTest.kt b/ai-logic/firebase-ai-ksp-processor/test-app/src/test/kotlin/com/google/firebase/testing/processor/FirebaseKspProcessorTest.kt index 3e58812b0fb..5f250a25884 100644 --- a/ai-logic/firebase-ai-ksp-processor/test-app/src/test/kotlin/com/google/firebase/testing/processor/FirebaseKspProcessorTest.kt +++ b/ai-logic/firebase-ai-ksp-processor/test-app/src/test/kotlin/com/google/firebase/testing/processor/FirebaseKspProcessorTest.kt @@ -77,13 +77,18 @@ class FirebaseKspProcessorTest { assertThat(enumSchema.title).isEqualTo("enumTest") assertThat(enumSchema.nullable).isEqualTo(false) - assertThat(rootSchema.properties?.get("objTest")).isNotNull() - val objSchema = rootSchema.properties?.get("objTest")!! + assertThat(rootSchema.properties?.get("nestedSchemaTest")).isNotNull() + val objSchema = rootSchema.properties?.get("nestedSchemaTest")!! assertThat(objSchema.clazz).isEqualTo(SecondarySchemaTestClass::class) assertThat(objSchema.properties).isNotNull() - assertThat(objSchema.description) - .isEqualTo("class kdoc should be used if property kdocs aren't present") - assertThat(objSchema.title).isEqualTo("objTest") + assertThat(objSchema.description).isNull() + assertThat(objSchema.title).isEqualTo("nestedSchemaTest") assertThat(objSchema.nullable).isEqualTo(false) + + assertThat(rootSchema.properties?.get("stringEnumTest")).isNotNull() + val stringEnumSchema = rootSchema.properties?.get("stringEnumTest")!! + assertThat(stringEnumSchema.enum).isEqualTo(listOf("NORTH", "SOUTH", "EAST", "WEST")) + assertThat(stringEnumSchema.title).isEqualTo("stringEnumTest") + assertThat(stringEnumSchema.nullable).isEqualTo(false) } } diff --git a/ai-logic/firebase-ai-ksp-processor/test-app/test-app.gradle.kts b/ai-logic/firebase-ai-ksp-processor/test-app/test-app.gradle.kts index c733ec3b6bd..c18fd3eeab7 100644 --- a/ai-logic/firebase-ai-ksp-processor/test-app/test-app.gradle.kts +++ b/ai-logic/firebase-ai-ksp-processor/test-app/test-app.gradle.kts @@ -28,7 +28,7 @@ android { compileSdk = 36 defaultConfig { applicationId = "com.google.firebase.testing.processor" - minSdk = 23 + minSdk = 26 targetSdk = 36 versionCode = 1 versionName = "1.0" @@ -41,10 +41,18 @@ android { } } -kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_1_8 } } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + freeCompilerArgs.add("-Xskip-metadata-version-check") + } +} dependencies { implementation(project(":ai-logic:firebase-ai")) + implementation(project(":ai-logic:firebase-ai-ondevice")) + + implementation(libs.genai.schema) ksp(project(":ai-logic:firebase-ai-ksp-processor")) implementation("com.google.firebase:firebase-common:22.0.0") diff --git a/ai-logic/firebase-ai-ondevice-interop/api.txt b/ai-logic/firebase-ai-ondevice-interop/api.txt index 42f19306399..23296a90a6b 100644 --- a/ai-logic/firebase-ai-ondevice-interop/api.txt +++ b/ai-logic/firebase-ai-ondevice-interop/api.txt @@ -105,6 +105,13 @@ package com.google.firebase.ai.ondevice.interop { property public final String? modelVersion; } + public final class GenerateObjectResponse { + ctor public GenerateObjectResponse(java.util.List instances); + ctor public GenerateObjectResponse(T instance); + method public java.util.List getInstances(); + property public final java.util.List instances; + } + public final class GenerationConfig { ctor public GenerationConfig(com.google.firebase.ai.ondevice.interop.ModelConfig modelConfig); method public com.google.firebase.ai.ondevice.interop.ModelConfig getModelConfig(); @@ -117,6 +124,7 @@ package com.google.firebase.ai.ondevice.interop { method public kotlinx.coroutines.flow.Flow download(); method public suspend Object? generateContent(com.google.firebase.ai.ondevice.interop.GenerateContentRequest request, kotlin.coroutines.Continuation); method public kotlinx.coroutines.flow.Flow generateContentStream(com.google.firebase.ai.ondevice.interop.GenerateContentRequest request); + method public suspend Object? generateObject(com.google.firebase.ai.ondevice.interop.GenerateContentRequest request, kotlin.reflect.KClass schemaClass, kotlin.coroutines.Continuation>); method public suspend Object? getBaseModelName(kotlin.coroutines.Continuation); method public suspend Object? getTokenLimit(kotlin.coroutines.Continuation); method public suspend Object? isAvailable(kotlin.coroutines.Continuation); diff --git a/ai-logic/firebase-ai-ondevice-interop/src/main/kotlin/com/google/firebase/ai/ondevice/interop/GenerateObjectResponse.kt b/ai-logic/firebase-ai-ondevice-interop/src/main/kotlin/com/google/firebase/ai/ondevice/interop/GenerateObjectResponse.kt new file mode 100644 index 00000000000..e461d53f4d0 --- /dev/null +++ b/ai-logic/firebase-ai-ondevice-interop/src/main/kotlin/com/google/firebase/ai/ondevice/interop/GenerateObjectResponse.kt @@ -0,0 +1,27 @@ +/* + * 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.ondevice.interop + +/** + * Represents a structured object generation response from the on-device model interop layer. + * + * @property instances The list of generated object instances across all candidates returned + * directly by the model engine. + */ +public class GenerateObjectResponse(public val instances: List) { + public constructor(instance: T) : this(listOf(instance)) +} diff --git a/ai-logic/firebase-ai-ondevice-interop/src/main/kotlin/com/google/firebase/ai/ondevice/interop/GenerativeModel.kt b/ai-logic/firebase-ai-ondevice-interop/src/main/kotlin/com/google/firebase/ai/ondevice/interop/GenerativeModel.kt index e7e03b7e37f..71634edff6b 100644 --- a/ai-logic/firebase-ai-ondevice-interop/src/main/kotlin/com/google/firebase/ai/ondevice/interop/GenerativeModel.kt +++ b/ai-logic/firebase-ai-ondevice-interop/src/main/kotlin/com/google/firebase/ai/ondevice/interop/GenerativeModel.kt @@ -45,6 +45,21 @@ public interface GenerativeModel { */ public suspend fun generateContent(request: GenerateContentRequest): GenerateContentResponse + /** + * Generates a structured object from the input [GenerateContentRequest] given to the model as a + * prompt. + * + * @param request The input given to the model as a prompt. + * @param schemaClass The target SDK data class (`T`, e.g. `MovieReview::class`), from which the + * underlying engine dynamically resolves and maps the KSP shadow companion class at runtime. + * @throws [FirebaseAIOnDeviceNotAvailableException] if model is not available. + * @return The structured object response generated by the model. + */ + public suspend fun generateObject( + request: GenerateContentRequest, + schemaClass: kotlin.reflect.KClass + ): GenerateObjectResponse + /** * Counts the number of tokens in a prompt using the model's tokenizer. * diff --git a/ai-logic/firebase-ai-ondevice/firebase-ai-ondevice.gradle.kts b/ai-logic/firebase-ai-ondevice/firebase-ai-ondevice.gradle.kts index 068d7f9595c..8af7aad3a0d 100644 --- a/ai-logic/firebase-ai-ondevice/firebase-ai-ondevice.gradle.kts +++ b/ai-logic/firebase-ai-ondevice/firebase-ai-ondevice.gradle.kts @@ -63,13 +63,16 @@ android { } kotlin { - compilerOptions { jvmTarget = JvmTarget.JVM_1_8 } + compilerOptions { + jvmTarget = JvmTarget.JVM_1_8 + freeCompilerArgs.add("-Xskip-metadata-version-check") + } explicitApi() } dependencies { implementation(libs.genai.prompt) - implementation("com.google.firebase:firebase-ai-ondevice-interop:16.0.0-beta03") + implementation(project(":ai-logic:firebase-ai-ondevice-interop")) implementation(libs.firebase.common) implementation(libs.firebase.components) diff --git a/ai-logic/firebase-ai-ondevice/src/main/kotlin/com/google/firebase/ai/ondevice/GenerativeModelImpl.kt b/ai-logic/firebase-ai-ondevice/src/main/kotlin/com/google/firebase/ai/ondevice/GenerativeModelImpl.kt index e5ecd485014..3f00f0cb5d4 100644 --- a/ai-logic/firebase-ai-ondevice/src/main/kotlin/com/google/firebase/ai/ondevice/GenerativeModelImpl.kt +++ b/ai-logic/firebase-ai-ondevice/src/main/kotlin/com/google/firebase/ai/ondevice/GenerativeModelImpl.kt @@ -53,6 +53,55 @@ internal class GenerativeModelImpl( throw getMappingException(e) } + override suspend fun generateObject( + request: GenerateContentRequest, + schemaClass: kotlin.reflect.KClass + ): com.google.firebase.ai.ondevice.interop.GenerateObjectResponse = + try { + val companionClassName = "${schemaClass.java.name}_MlKitCompanion" + val targetClass = + try { + Class.forName(companionClassName).kotlin + } catch (e: Exception) { + if (schemaClass.java.name.endsWith("_MlKitCompanion")) { + schemaClass + } else { + throw IllegalArgumentException( + "Shadow class $companionClassName not found for structured output. Ensure KSP processor is running and generated the MlKit companion class.", + e + ) + } + } + + @Suppress("UNCHECKED_CAST") + val mlkitRequest = + com.google.mlkit.genai.prompt.generateTypedContentRequest( + generateContentRequest = request.toMlKit(), + outputClass = targetClass as kotlin.reflect.KClass, + includeSchemaInPrompt = true + ) + val typedResponse = mlkitModel.generateContent(mlkitRequest) + val mlkitInstances = typedResponse.candidates.mapNotNull { it.response } + if (mlkitInstances.isEmpty()) { + throw GenAiException("No response candidate returned by ML Kit", null, 0) + } + + @Suppress("UNCHECKED_CAST") + val sdkInstances = + mlkitInstances.map { mlkitInstance -> + if (mlkitInstance.javaClass.name.endsWith("_MlKitCompanion")) { + val toSdkMethod = mlkitInstance.javaClass.getMethod("toSdk") + toSdkMethod.invoke(mlkitInstance) as T + } else { + mlkitInstance as T + } + } + + com.google.firebase.ai.ondevice.interop.GenerateObjectResponse(sdkInstances) + } catch (e: GenAiException) { + throw getMappingException(e) + } + override suspend fun countTokens(request: GenerateContentRequest): CountTokensResponse = try { val response = mlkitModel.countTokens(request.toMlKit()) diff --git a/ai-logic/firebase-ai-ondevice/src/test/kotlin/com/google/firebase/ai/ondevice/ConvertersTest.kt b/ai-logic/firebase-ai-ondevice/src/test/kotlin/com/google/firebase/ai/ondevice/ConvertersTest.kt index 92c51816ad7..33dcfd31c04 100644 --- a/ai-logic/firebase-ai-ondevice/src/test/kotlin/com/google/firebase/ai/ondevice/ConvertersTest.kt +++ b/ai-logic/firebase-ai-ondevice/src/test/kotlin/com/google/firebase/ai/ondevice/ConvertersTest.kt @@ -95,13 +95,9 @@ internal class ConvertersTest { val interopRequest = InteropGenerateContentRequest(text = InteropTextPart("prompt")) val mlKitRequest = interopRequest.toMlKit() - // Documented default values + // We only assert the fields we explicitly set, + // as ML Kit's internal defaults may vary by version or environment. assertThat(mlKitRequest.text.textString).isEqualTo("prompt") - assertThat(mlKitRequest.temperature).isEqualTo(0.0f) - assertThat(mlKitRequest.topK).isEqualTo(3) - assertThat(mlKitRequest.seed).isEqualTo(0) - assertThat(mlKitRequest.candidateCount).isEqualTo(1) - assertThat(mlKitRequest.maxOutputTokens).isEqualTo(256) } @Test diff --git a/ai-logic/firebase-ai/api.txt b/ai-logic/firebase-ai/api.txt index 9b02089f183..9d87e8feb61 100644 --- a/ai-logic/firebase-ai/api.txt +++ b/ai-logic/firebase-ai/api.txt @@ -229,12 +229,14 @@ package com.google.firebase.ai.annotations { @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.PROPERTY) public @interface Guide { method public abstract String description() default ""; + method public abstract String[] enumValues(); method public abstract String format() default ""; method public abstract int maxItems() default -1; method public abstract double maximum() default -1.0; method public abstract int minItems() default -1; method public abstract double minimum() default -1.0; property public abstract String description; + property public abstract String[] enumValues; property public abstract String format; property public abstract int maxItems; property public abstract double maximum; diff --git a/ai-logic/firebase-ai/firebase-ai.gradle.kts b/ai-logic/firebase-ai/firebase-ai.gradle.kts index af6243f6760..5925a75e3d9 100644 --- a/ai-logic/firebase-ai/firebase-ai.gradle.kts +++ b/ai-logic/firebase-ai/firebase-ai.gradle.kts @@ -98,7 +98,7 @@ dependencies { implementation("androidx.concurrent:concurrent-futures:1.2.0") implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0") implementation("com.google.firebase:firebase-auth-interop:18.0.0") - implementation("com.google.firebase:firebase-ai-ondevice-interop:16.0.0-beta03") + implementation(project(":ai-logic:firebase-ai-ondevice-interop")) // Use different logging libraries depending on the variant releaseImplementation(libs.slf4j.nop) diff --git a/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt b/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt index d11af433b24..eb7acfd7492 100644 --- a/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt +++ b/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt @@ -35,4 +35,5 @@ public annotation class Guide( public val minItems: Int = -1, public val maxItems: Int = -1, public val format: String = "", + public val enumValues: Array = [], ) diff --git a/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/generativemodel/OnDeviceGenerativeModelProvider.kt b/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/generativemodel/OnDeviceGenerativeModelProvider.kt index 5471db5dfdb..7f1528ec6e9 100644 --- a/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/generativemodel/OnDeviceGenerativeModelProvider.kt +++ b/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/generativemodel/OnDeviceGenerativeModelProvider.kt @@ -27,6 +27,7 @@ import com.google.firebase.ai.ondevice.interop.TextPart as OnDeviceTextPart import com.google.firebase.ai.type.Candidate import com.google.firebase.ai.type.Content import com.google.firebase.ai.type.CountTokensResponse +import com.google.firebase.ai.type.FinishReason import com.google.firebase.ai.type.FirebaseAIException import com.google.firebase.ai.type.GenerateContentResponse import com.google.firebase.ai.type.GenerateObjectResponse @@ -34,6 +35,7 @@ import com.google.firebase.ai.type.ImagePart import com.google.firebase.ai.type.JsonSchema import com.google.firebase.ai.type.PublicPreviewAPI import com.google.firebase.ai.type.TextPart +import com.google.firebase.ai.type.content import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emitAll @@ -133,19 +135,35 @@ internal class OnDeviceGenerativeModelProvider( /** * Generates a structured object based on the given prompt and schema. * - * Note: This is currently not supported for on-device models. - * * @param jsonSchema The schema defining the structure of the output. * @param prompt The list of content parts to use as the prompt. * @return The generated object response. - * @throws FirebaseAIException Always throws as this feature is not supported. + * @throws FirebaseAIException If the on-device model is unavailable or if generation fails. */ override suspend fun generateObject( jsonSchema: JsonSchema, prompt: List - ): GenerateObjectResponse { - throw FirebaseAIException.from( - IllegalArgumentException("On-device mode is not supported for `generateObject`") + ): GenerateObjectResponse = withFirebaseAIExceptionHandling { + ensureOnDeviceModelAvailable() + + val request = buildOnDeviceGenerateContentRequest(prompt) + val interopResponse = onDeviceModel.generateObject(request, jsonSchema.clazz) + val candidates = + interopResponse.instances.map { + Candidate( + content = content { text("") }, + safetyRatings = emptyList(), + citationMetadata = null, + finishReason = FinishReason.STOP, + finishMessage = null, + groundingMetadata = null, + urlContextMetadata = null + ) + } + @Suppress("UNCHECKED_CAST") + GenerateObjectResponse( + GenerateContentResponse(candidates, InferenceSource.ON_DEVICE, null, null, "ondevice"), + instances = interopResponse.instances as List ) } diff --git a/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt b/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt index 59fe8e5db02..591b2fa0893 100644 --- a/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt +++ b/ai-logic/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt @@ -27,9 +27,20 @@ import kotlinx.serialization.json.Json public class GenerateObjectResponse internal constructor( public val response: GenerateContentResponse, - internal val schema: JsonSchema + internal val schema: JsonSchema?, + internal var instances: MutableList? ) { + internal constructor( + response: GenerateContentResponse, + schema: JsonSchema + ) : this(response, schema = schema, instances = null) + + internal constructor( + response: GenerateContentResponse, + instances: List + ) : this(response, schema = null, instances = ArrayList(instances)) + /** * Deserialize a candidate (default first) and convert it into the type associated with this * response. @@ -39,18 +50,42 @@ internal constructor( * @throws SerializationException if an error occurs during deserialization */ @OptIn(InternalSerializationApi::class) - public fun getObject(candidateIndex: Int = 0): T? { - val candidate = response.candidates[candidateIndex] - - val deserializer = schema.getSerializer() - val text = - candidate.content.parts - .filter { !it.isThought } - .filterIsInstance() - .joinToString(" ") { it.text } - if (text.isEmpty()) { - return null + public fun getObject(candidateIndex: Int = 0): T? = + synchronized(this) { + val insts = instances + + // 1. Fast Path / Cache Hit: Return immediately if already resolved or in memory + insts?.getOrNull(candidateIndex)?.let { + return it + } + + // 2. Cache Miss (Cloud response on first access): Deserialize using schema + if (schema == null) return null + val candidate = response.candidates.getOrNull(candidateIndex) ?: return null + val text = + candidate.content.parts + .filter { !it.isThought } + .filterIsInstance() + .joinToString(" ") { it.text } + + val deserialized = + if (text.isEmpty()) { + null + } else { + try { + Json.decodeFromString(schema.getSerializer(), text) as T? + } catch (e: Exception) { + null + } + } + + // 3. Save to instances list for future accesses (lazy loading) and return + val currentInstances = + insts ?: MutableList(response.candidates.size) { null }.also { instances = it } + + if (candidateIndex < currentInstances.size) { + currentInstances[candidateIndex] = deserialized + } + return deserialized } - return Json.decodeFromString(deserializer, text) as T? - } } diff --git a/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/FallbackGenerativeModelProviderTests.kt b/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/FallbackGenerativeModelProviderTests.kt index ae7b6d75e49..bdc32576751 100644 --- a/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/FallbackGenerativeModelProviderTests.kt +++ b/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/FallbackGenerativeModelProviderTests.kt @@ -254,6 +254,42 @@ internal class FallbackGenerativeModelProviderTests { verify(exactly = 0) { fallbackModel.generateContentStream(prompt) } } + @Test + fun `generateObject uses default model when successful`() = runBlocking { + val schema: JsonSchema = mockk() + val expectedResponse: GenerateObjectResponse = mockk() + coEvery { defaultModel.generateObject(schema, prompt) } returns expectedResponse + + val provider = FallbackGenerativeModelProvider(defaultModel, fallbackModel) + shouldNotThrowAnyUnit { + val response = provider.generateObject(schema, prompt) + + response shouldBe expectedResponse + coVerify(exactly = 0) { + fallbackModel.generateObject(any>(), any>()) + } + } + } + + @Test + fun `generateObject falls back when precondition fails`() = runBlocking { + val schema: JsonSchema = mockk() + val expectedResponse: GenerateObjectResponse = mockk() + coEvery { fallbackModel.generateObject(schema, prompt) } returns expectedResponse + + val provider = + FallbackGenerativeModelProvider(defaultModel, fallbackModel, precondition = { false }) + shouldNotThrowAnyUnit { + val response = provider.generateObject(schema, prompt) + + response shouldBe expectedResponse + coVerify(exactly = 0) { + defaultModel.generateObject(any>(), any>()) + } + coVerify { fallbackModel.generateObject(schema, prompt) } + } + } + @Test fun `generateObject falls back when default model throws FirebaseAIException`() = runBlocking { val schema: JsonSchema = mockk() @@ -272,6 +308,22 @@ internal class FallbackGenerativeModelProviderTests { } } + @Test + fun `generateObject rethrows CancellationException and does not fall back`() = runBlocking { + val schema: JsonSchema = mockk() + val exception = kotlinx.coroutines.CancellationException("cancelled") + coEvery { defaultModel.generateObject(schema, prompt) } throws exception + + val provider = FallbackGenerativeModelProvider(defaultModel, fallbackModel) + + shouldThrow { + provider.generateObject(schema, prompt) + } + coVerify(exactly = 0) { + fallbackModel.generateObject(any>(), any>()) + } + } + @Test fun `generateContent rethrows CancellationException and does not fall back`() = runBlocking { val exception = kotlinx.coroutines.CancellationException("cancelled") diff --git a/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/OnDeviceGenerativeModelProviderTests.kt b/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/OnDeviceGenerativeModelProviderTests.kt index 9ef22433578..e548cf492ed 100644 --- a/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/OnDeviceGenerativeModelProviderTests.kt +++ b/ai-logic/firebase-ai/src/test/java/com/google/firebase/ai/generativeModel/OnDeviceGenerativeModelProviderTests.kt @@ -107,12 +107,27 @@ internal class OnDeviceGenerativeModelProviderTests { response.inferenceSource shouldBe InferenceSource.ON_DEVICE } + private data class TestMovieReview(val title: String, val rating: Int) + @Test - fun `generateObject always throws FirebaseAIException`(): Unit = runBlocking { - val schema = mockk>() + fun `generateObject delegates to onDeviceModel`(): Unit = runBlocking { + coEvery { onDeviceModel.isAvailable() } returns true + val dummyInstance = TestMovieReview("Inception", 5) + val schema: JsonSchema = mockk { + every { clazz } returns TestMovieReview::class + } + val interopResponse = + com.google.firebase.ai.ondevice.interop.GenerateObjectResponse( + instance = dummyInstance + ) + coEvery { + onDeviceModel.generateObject(any(), any>()) + } returns interopResponse - val exception = shouldThrow { provider.generateObject(schema, prompt) } - exception.cause!!::class shouldBe IllegalArgumentException::class + val response = provider.generateObject(schema, prompt) + + response.response.inferenceSource shouldBe InferenceSource.ON_DEVICE + response.getObject() shouldBe dummyInstance } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 618f5ee8c6e..b3e1e3f5a38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,8 @@ firebaseAnnotations = "17.0.0" firebaseCommon = "22.0.1" firebaseComponents = "19.0.0" firebaseCrashlyticsGradle = "3.0.4" -genaiPrompt = "1.0.0-beta2" +genaiPrompt = "1.0.0-beta3" +genaiSchema = "1.0.0-alpha1" glide = "5.0.5" googleApiClient = "2.8.1" googleServices = "4.3.15" @@ -121,6 +122,7 @@ firebase-annotations = { module = "com.google.firebase:firebase-annotations", ve firebase-common = { module = "com.google.firebase:firebase-common", version.ref = "firebaseCommon" } firebase-components = { module = "com.google.firebase:firebase-components", version.ref = "firebaseComponents" } genai-prompt = { module = "com.google.mlkit:genai-prompt", version.ref = "genaiPrompt" } +genai-schema = { module = "com.google.mlkit:genai-schema", version.ref = "genaiSchema" } glide = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } google-api-client = { module = "com.google.api-client:google-api-client", version.ref = "googleApiClient" } google-dexmaker = { module = "com.google.dexmaker:dexmaker", version.ref = "dexmakerVersion" }