From 2b5754958316270978b29a7b2986e6dc16af3977 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 30 Oct 2025 12:11:32 -0700 Subject: [PATCH 01/30] add KSP processor for Firebase AI --- gradle/libs.versions.toml | 4 ++++ subprojects.cfg | 2 ++ 2 files changed, 6 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8636054f4e..71e540e1676 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -45,6 +45,7 @@ jsonassert = "1.5.0" kotest = "5.9.0" # Do not use 5.9.1 because it reverts the fix for https://github.com/kotest/kotest/issues/3981 kotestAssertionsCore = "5.8.1" kotlin = "2.0.21" +kotlinpoetKsp = "2.2.0" ktorVersion = "3.0.3" legacySupportV4 = "1.0.0" lifecycleProcess = "2.3.1" @@ -69,6 +70,7 @@ rxjava = "2.2.21" serialization = "1.7.3" slf4jNop = "2.0.17" spotless = "7.0.4" +symbolProcessingApi = "2.2.10-2.0.2" testServices = "1.6.0" truth = "1.4.4" truthProtoExtension = "1.0" @@ -142,6 +144,7 @@ kotlin-bom = { module = "org.jetbrains.kotlin:kotlin-bom", version.ref = "kotlin kotlin-coroutines-tasks = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "coroutines" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoetKsp" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-reactive = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-reactive", version.ref = "coroutines" } @@ -203,6 +206,7 @@ rxandroid = { module = "io.reactivex.rxjava2:rxandroid", version.ref = "rxandroi rxjava = { module = "io.reactivex.rxjava2:rxjava", version.ref = "rxjava" } slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4jNop" } spotless-plugin-gradle = { module = "com.diffplug.spotless:spotless-plugin-gradle", version.ref = "spotless" } +symbol-processing-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "symbolProcessingApi" } truth = { module = "com.google.truth:truth", version.ref = "truth" } truth-liteproto-extension = { module = "com.google.truth.extensions:truth-liteproto-extension", version.ref = "truth" } truth-proto-extension = { module = "com.google.truth.extensions:truth-proto-extension", version.ref = "truthProtoExtension" } diff --git a/subprojects.cfg b/subprojects.cfg index f8505ecf8e7..a167e6eb58c 100644 --- a/subprojects.cfg +++ b/subprojects.cfg @@ -74,3 +74,5 @@ transport:transport-runtime-testing # sdk #firebase-storage:test-app #appcheck:firebase-appcheck:test-app #firebase-appdistribution:test-app + +firebase-ai-ksp-processor # buildtools From 86430467c9e70f5289c6fd49b88607acf82a9b22 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 30 Oct 2025 12:11:51 -0700 Subject: [PATCH 02/30] add the code --- firebase-ai-ksp-processor/README.md | 13 + firebase-ai-ksp-processor/build.gradle.kts | 46 +++ firebase-ai-ksp-processor/gradle.properties | 1 + .../firebase/ai/annotations/Generable.kt | 21 ++ .../google/firebase/ai/annotations/Guide.kt | 28 ++ .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 298 ++++++++++++++++++ .../ai/ksp/SchemaSymbolProcessorProvider.kt | 27 ++ ...ols.ksp.processing.SymbolProcessorProvider | 1 + 8 files changed, 435 insertions(+) create mode 100644 firebase-ai-ksp-processor/README.md create mode 100644 firebase-ai-ksp-processor/build.gradle.kts create mode 100644 firebase-ai-ksp-processor/gradle.properties create mode 100644 firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt create mode 100644 firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt create mode 100644 firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt create mode 100644 firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorProvider.kt create mode 100644 firebase-ai-ksp-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider diff --git a/firebase-ai-ksp-processor/README.md b/firebase-ai-ksp-processor/README.md new file mode 100644 index 00000000000..961100598df --- /dev/null +++ b/firebase-ai-ksp-processor/README.md @@ -0,0 +1,13 @@ +To build run `./gradlew :publishToMavenLocal` + +To integrate: add the following to your app's gradle file: + +```declarative +plugins { + id("com.google.devtools.ksp") +} +dependencies { + implementation("com.google.firebase:firebase-ai-ksp-processor:1.0.0") + ksp("com.google.firebase:firebase-ai-ksp-processor:1.0.0") +} +``` diff --git a/firebase-ai-ksp-processor/build.gradle.kts b/firebase-ai-ksp-processor/build.gradle.kts new file mode 100644 index 00000000000..bd3304eb6ad --- /dev/null +++ b/firebase-ai-ksp-processor/build.gradle.kts @@ -0,0 +1,46 @@ +/* + * 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. + */ + +plugins { + kotlin("jvm") + id("java-library") + id("maven-publish") +} + +dependencies { + testImplementation(kotlin("test")) + implementation(libs.symbol.processing.api) + implementation(libs.kotlinpoet.ksp) +} + +tasks.test { useJUnitPlatform() } + +kotlin { jvmToolchain(21) } + +publishing { + publications { + create("mavenKotlin") { + from(components["kotlin"]) + groupId = "com.google.firebase" + artifactId = "firebase-ai-ksp-processor" + version = "1.0.0" + } + } + repositories { + maven { url = uri("m2/") } + mavenLocal() + } +} diff --git a/firebase-ai-ksp-processor/gradle.properties b/firebase-ai-ksp-processor/gradle.properties new file mode 100644 index 00000000000..7fc6f1ff272 --- /dev/null +++ b/firebase-ai-ksp-processor/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt new file mode 100644 index 00000000000..9cd1370b4d0 --- /dev/null +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt @@ -0,0 +1,21 @@ +/* + * 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. + */ + +package com.google.firebase.ai.annotations + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.SOURCE) +public annotation class Generable() diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt new file mode 100644 index 00000000000..1a060721b00 --- /dev/null +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt @@ -0,0 +1,28 @@ +/* + * 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. + */ + +package com.google.firebase.ai.annotations + +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.SOURCE) +public annotation class Guide( + public val description: String = "", + public val minimum: Double = -1.0, + public val maximum: Double = -1.0, + public val minItems: Int = -1, + public val maxItems: Int = -1, + public val format: String = "", +) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt new file mode 100644 index 00000000000..75b453b862f --- /dev/null +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -0,0 +1,298 @@ +/* + * 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. + */ + +package com.google.firebase.ai.ksp + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +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.google.firebase.ai.annotations.Generable +import com.google.firebase.ai.annotations.Guide +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.writeTo + +public class SchemaSymbolProcessor( + private val codeGenerator: CodeGenerator, + private val logger: KSPLogger, +) : SymbolProcessor { + override fun process(resolver: Resolver): List { + resolver + .getSymbolsWithAnnotation(Generable::class.qualifiedName.orEmpty()) + .filterIsInstance() + .map { it to SchemaSymbolProcessorVisitor(it, resolver) } + .forEach { it.second.visitClassDeclaration(it.first, Unit) } + + return emptyList() + } + + private inner class SchemaSymbolProcessorVisitor( + private val klass: KSClassDeclaration, + private val resolver: Resolver, + ) : KSVisitorVoid() { + private val numberTypes = setOf("kotlin.Int", "kotlin.Long", "kotlin.Double", "kotlin.Float") + private val baseKdocRegex = Regex("^\\s*(.*?)((@\\w* .*)|\\z)", RegexOption.DOT_MATCHES_ALL) + private val propertyKdocRegex = + Regex("\\s*@property (\\w*) (.*?)(?=@\\w*|\\z)", RegexOption.DOT_MATCHES_ALL) + + override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { + val isDataClass = classDeclaration.modifiers.contains(Modifier.DATA) + if (!isDataClass) { + logger.error("${classDeclaration.qualifiedName} is not a data class") + } + val generatedSchemaFile = generateFileSpec(classDeclaration) + generatedSchemaFile.writeTo( + codeGenerator, + Dependencies(true, classDeclaration.containingFile!!), + ) + } + + fun generateFileSpec(classDeclaration: KSClassDeclaration): FileSpec { + return FileSpec.builder( + classDeclaration.packageName.asString(), + "${classDeclaration.simpleName.asString()}GeneratedSchema", + ) + .addImport("com.google.firebase.ai.type", "Schema") + .addType( + TypeSpec.classBuilder("${classDeclaration.simpleName.asString()}GeneratedSchema") + .addType( + TypeSpec.companionObjectBuilder() + .addProperty( + PropertySpec.builder( + "SCHEMA", + ClassName("com.google.firebase.ai.type", "Schema"), + KModifier.PUBLIC, + ) + .mutable(false) + .initializer( + CodeBlock.builder() + .add( + generateCodeBlockForSchema(type = classDeclaration.asType(emptyList())) + ) + .build() + ) + .build() + ) + .build() + ) + .build() + ) + .build() + } + + @OptIn(KspExperimental::class) + fun generateCodeBlockForSchema( + name: String? = null, + description: String? = null, + type: KSType, + parentType: KSType? = null, + guideAnnotation: KSAnnotation? = null, + ): CodeBlock { + val parameterizedName = type.toTypeName() as? ParameterizedTypeName + val className = parameterizedName?.rawType ?: type.toClassName() + val kdocString = type.declaration.docString ?: "" + val baseKdoc = extractBaseKdoc(kdocString) + val propertyDocs = extractPropertyKdocs(kdocString) + val guideClassAnnotation = + type.annotations.firstOrNull() { + it.shortName.getShortName() == Guide::class.java.simpleName + } + val description = + getDescriptionFromAnnotations(guideAnnotation, guideClassAnnotation, description, baseKdoc) + val minimum = getDoubleFromAnnotation(guideAnnotation, "minimum") + val maximum = getDoubleFromAnnotation(guideAnnotation, "maximum") + val minItems = getIntFromAnnotation(guideAnnotation, "minItems") + val maxItems = getIntFromAnnotation(guideAnnotation, "maxItems") + val format = getStringFromAnnotation(guideAnnotation, "format") + val builder = CodeBlock.builder() + when (className.canonicalName) { + "kotlin.Int" -> { + builder.addStatement("Schema.integer(") + } + "kotlin.Long" -> { + builder.addStatement("Schema.long(") + } + "kotlin.Boolean" -> { + builder.addStatement("Schema.boolean(") + } + "kotlin.Float" -> { + builder.addStatement("Schema.float(") + } + "kotlin.Double" -> { + builder.addStatement("Schema.double(") + } + "kotlin.String" -> { + builder.addStatement("Schema.string(") + } + else -> { + if (className.canonicalName == "kotlin.collections.List") { + val listTypeParam = type.arguments.first().type!!.resolve() + val listParamCodeBlock = + generateCodeBlockForSchema(type = listTypeParam, parentType = type) + builder.addStatement("Schema.array(items = ").add(listParamCodeBlock).addStatement(",") + } else { + builder.addStatement("Schema.obj(properties = ") + val properties = + (type.declaration as KSClassDeclaration).getAllProperties().associate { property -> + val propertyName = property.simpleName.asString() + propertyName to + generateCodeBlockForSchema( + type = property.type.resolve(), + parentType = type, + description = propertyDocs[propertyName], + name = propertyName, + guideAnnotation = + property.annotations.firstOrNull() { + it.shortName.getShortName() == Guide::class.java.simpleName + }, + ) + } + builder.addStatement("mapOf(") + properties.entries.forEach { + builder.addStatement("%S to ", it.key).add(it.value).addStatement(", ") + } + builder.addStatement("),") + } + } + } + if (name != null) { + builder.addStatement("title = %S,", name) + } + if (description != null) { + builder.addStatement("description = %S,", description) + } + if ((minimum != null || maximum != null) && !numberTypes.contains(className.canonicalName)) { + logger.warn( + "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a number type, minimum and maximum are not valid parameters to specify in @Guide" + ) + } + if ( + (minItems != null || maxItems != null) && + className.canonicalName !== "kotlin.collections.List" + ) { + logger.warn( + "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a List type, minItems and maxItems are not valid parameters to specify in @Guide" + ) + } + if (format != null && className.canonicalName !== "kotlin.String") { + logger.warn( + "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, format is not a valid parameter to specify in @Guide" + ) + } + if (minimum != null) { + builder.addStatement("minimum = %L,", minimum) + } + if (maximum != null) { + builder.addStatement("maximum = %L,", maximum) + } + if (minItems != null) { + builder.addStatement("minItems = %L,", minItems) + } + if (maxItems != null) { + builder.addStatement("maxItems = %L,", maxItems) + } + if (format != null) { + builder.addStatement("format = %S,", format) + } + builder.addStatement("nullable = %L)", className.isNullable) + return builder.build() + } + + private fun getDescriptionFromAnnotations( + guideAnnotation: KSAnnotation?, + guideClassAnnotation: KSAnnotation?, + description: String?, + baseKdoc: String?, + ): String? { + val guidePropertyDescription = getStringFromAnnotation(guideAnnotation, "description") + + val guideClassDescription = getStringFromAnnotation(guideClassAnnotation, "description") + + return guidePropertyDescription ?: guideClassDescription ?: description ?: baseKdoc + } + + private fun getDoubleFromAnnotation( + guideAnnotation: KSAnnotation?, + doubleName: String, + ): Double? { + val guidePropertyDoubleValue = + guideAnnotation + ?.arguments + ?.firstOrNull { it.name?.getShortName()?.equals(doubleName) == true } + ?.value as? Double + if (guidePropertyDoubleValue == null || guidePropertyDoubleValue == -1.0) { + return null + } + return guidePropertyDoubleValue + } + + private fun getIntFromAnnotation(guideAnnotation: KSAnnotation?, intName: String): Int? { + val guidePropertyIntValue = + guideAnnotation + ?.arguments + ?.firstOrNull { it.name?.getShortName()?.equals(intName) == true } + ?.value as? Int + if (guidePropertyIntValue == null || guidePropertyIntValue == -1) { + return null + } + return guidePropertyIntValue + } + + private fun getStringFromAnnotation( + guideAnnotation: KSAnnotation?, + stringName: String, + ): String? { + val guidePropertyStringValue = + guideAnnotation + ?.arguments + ?.firstOrNull { it.name?.getShortName()?.equals(stringName) == true } + ?.value as? String + if (guidePropertyStringValue.isNullOrEmpty()) { + return null + } + return guidePropertyStringValue + } + + private fun extractBaseKdoc(kdoc: String): String? { + return baseKdocRegex.matchEntire(kdoc)?.groups?.get(1)?.value?.trim().let { + if (it.isNullOrEmpty()) null else it + } + } + + private fun extractPropertyKdocs(kdoc: String): Map { + return propertyKdocRegex + .findAll(kdoc) + .map { it.groups[1]!!.value to it.groups[2]!!.value.replace("\n", "").trim() } + .toMap() + } + } +} diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorProvider.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorProvider.kt new file mode 100644 index 00000000000..2c8015bc8a9 --- /dev/null +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessorProvider.kt @@ -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. + */ + +package com.google.firebase.ai.ksp + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +public class SchemaSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return SchemaSymbolProcessor(environment.codeGenerator, environment.logger) + } +} diff --git a/firebase-ai-ksp-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/firebase-ai-ksp-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 00000000000..83d92f28c7e --- /dev/null +++ b/firebase-ai-ksp-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +com.google.firebase.ai.ksp.SchemaSymbolProcessorProvider \ No newline at end of file From 811122b1e449c408b589669175d1abd832dd5ef0 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 30 Oct 2025 12:43:51 -0700 Subject: [PATCH 03/30] Update firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 75b453b862f..61911cc2c1f 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -203,7 +203,7 @@ public class SchemaSymbolProcessor( "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a List type, minItems and maxItems are not valid parameters to specify in @Guide" ) } - if (format != null && className.canonicalName !== "kotlin.String") { + if (format != null && className.canonicalName != "kotlin.String") { logger.warn( "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, format is not a valid parameter to specify in @Guide" ) From 0d5d3f750e252f0d5cf759372551ff003073d2b3 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 30 Oct 2025 12:44:00 -0700 Subject: [PATCH 04/30] Update firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 61911cc2c1f..e6a524a14c7 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -197,7 +197,7 @@ public class SchemaSymbolProcessor( } if ( (minItems != null || maxItems != null) && - className.canonicalName !== "kotlin.collections.List" + className.canonicalName != "kotlin.collections.List" ) { logger.warn( "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a List type, minItems and maxItems are not valid parameters to specify in @Guide" From e7a1ab82a463a10eb8f802d502281cea962cdbd3 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 30 Oct 2025 12:46:40 -0700 Subject: [PATCH 05/30] Update firebase-ai-ksp-processor/README.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- firebase-ai-ksp-processor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-ai-ksp-processor/README.md b/firebase-ai-ksp-processor/README.md index 961100598df..07cd50d27d2 100644 --- a/firebase-ai-ksp-processor/README.md +++ b/firebase-ai-ksp-processor/README.md @@ -2,7 +2,7 @@ To build run `./gradlew :publishToMavenLocal` To integrate: add the following to your app's gradle file: -```declarative +```kotlin plugins { id("com.google.devtools.ksp") } From 54e9466c667b5c92ab6ce051e2c2698fed61ab78 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 30 Oct 2025 12:46:48 -0700 Subject: [PATCH 06/30] Update firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../main/kotlin/com/google/firebase/ai/annotations/Generable.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt index 9cd1370b4d0..b4a5e652ae5 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt @@ -18,4 +18,4 @@ package com.google.firebase.ai.annotations @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) -public annotation class Generable() +public annotation class Generable From 368c2ecddd8e53fb11028639002bb26067a2e720 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Wed, 5 Nov 2025 08:04:51 -0800 Subject: [PATCH 07/30] add generated annotation to generated class --- .../kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index e6a524a14c7..618918f9019 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -40,6 +40,7 @@ import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.toClassName import com.squareup.kotlinpoet.ksp.toTypeName import com.squareup.kotlinpoet.ksp.writeTo +import javax.annotation.processing.Generated public class SchemaSymbolProcessor( private val codeGenerator: CodeGenerator, @@ -84,6 +85,7 @@ public class SchemaSymbolProcessor( .addImport("com.google.firebase.ai.type", "Schema") .addType( TypeSpec.classBuilder("${classDeclaration.simpleName.asString()}GeneratedSchema") + .addAnnotation(Generated::class) .addType( TypeSpec.companionObjectBuilder() .addProperty( From bf2aa4d092184a1a20b25e03b26d19e0c1816560 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Wed, 5 Nov 2025 11:47:47 -0800 Subject: [PATCH 08/30] improve file formatting and indentation --- .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 618918f9019..f5bf6fcccf3 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -138,31 +138,40 @@ public class SchemaSymbolProcessor( val builder = CodeBlock.builder() when (className.canonicalName) { "kotlin.Int" -> { - builder.addStatement("Schema.integer(") + builder.addStatement("Schema.integer(").indent() } "kotlin.Long" -> { - builder.addStatement("Schema.long(") + builder.addStatement("Schema.long(").indent() } "kotlin.Boolean" -> { - builder.addStatement("Schema.boolean(") + builder.addStatement("Schema.boolean(").indent() } "kotlin.Float" -> { - builder.addStatement("Schema.float(") + builder.addStatement("Schema.float(").indent() } "kotlin.Double" -> { - builder.addStatement("Schema.double(") + builder.addStatement("Schema.double(").indent() } "kotlin.String" -> { - builder.addStatement("Schema.string(") + builder.addStatement("Schema.string(").indent() } else -> { if (className.canonicalName == "kotlin.collections.List") { val listTypeParam = type.arguments.first().type!!.resolve() val listParamCodeBlock = generateCodeBlockForSchema(type = listTypeParam, parentType = type) - builder.addStatement("Schema.array(items = ").add(listParamCodeBlock).addStatement(",") + builder + .addStatement("Schema.array(") + .indent() + .addStatement("items = ") + .add(listParamCodeBlock) + .addStatement(",") } else { - builder.addStatement("Schema.obj(properties = ") + builder + .addStatement("Schema.obj(") + .indent() + .addStatement("properties = mapOf(") + .indent() val properties = (type.declaration as KSClassDeclaration).getAllProperties().associate { property -> val propertyName = property.simpleName.asString() @@ -178,11 +187,15 @@ public class SchemaSymbolProcessor( }, ) } - builder.addStatement("mapOf(") properties.entries.forEach { - builder.addStatement("%S to ", it.key).add(it.value).addStatement(", ") + builder + .addStatement("%S to ", it.key) + .indent() + .add(it.value) + .unindent() + .addStatement(", ") } - builder.addStatement("),") + builder.unindent().addStatement("),") } } } @@ -225,7 +238,7 @@ public class SchemaSymbolProcessor( if (format != null) { builder.addStatement("format = %S,", format) } - builder.addStatement("nullable = %L)", className.isNullable) + builder.addStatement("nullable = %L)", className.isNullable).unindent() return builder.build() } From 94a2e45711b754f8f2bd74faf87c0f105319a1b2 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Wed, 5 Nov 2025 11:53:32 -0800 Subject: [PATCH 09/30] move annotations to firebase-ai, and rename the artifact --- firebase-ai-ksp-processor/build.gradle.kts | 2 +- .../com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt | 6 +++--- .../kotlin/com/google/firebase/ai/annotations/Generable.kt | 0 .../main/kotlin/com/google/firebase/ai/annotations/Guide.kt | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename {firebase-ai-ksp-processor => firebase-ai}/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt (100%) rename {firebase-ai-ksp-processor => firebase-ai}/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt (100%) diff --git a/firebase-ai-ksp-processor/build.gradle.kts b/firebase-ai-ksp-processor/build.gradle.kts index bd3304eb6ad..16df3455759 100644 --- a/firebase-ai-ksp-processor/build.gradle.kts +++ b/firebase-ai-ksp-processor/build.gradle.kts @@ -35,7 +35,7 @@ publishing { create("mavenKotlin") { from(components["kotlin"]) groupId = "com.google.firebase" - artifactId = "firebase-ai-ksp-processor" + artifactId = "firebase-ai-processor" version = "1.0.0" } } diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index f5bf6fcccf3..aee193f7302 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -48,7 +48,7 @@ public class SchemaSymbolProcessor( ) : SymbolProcessor { override fun process(resolver: Resolver): List { resolver - .getSymbolsWithAnnotation(Generable::class.qualifiedName.orEmpty()) + .getSymbolsWithAnnotation("com.google.firebase.ai.annotations.Generable") .filterIsInstance() .map { it to SchemaSymbolProcessorVisitor(it, resolver) } .forEach { it.second.visitClassDeclaration(it.first, Unit) } @@ -126,7 +126,7 @@ public class SchemaSymbolProcessor( val propertyDocs = extractPropertyKdocs(kdocString) val guideClassAnnotation = type.annotations.firstOrNull() { - it.shortName.getShortName() == Guide::class.java.simpleName + it.shortName.getShortName() == "Guide" } val description = getDescriptionFromAnnotations(guideAnnotation, guideClassAnnotation, description, baseKdoc) @@ -183,7 +183,7 @@ public class SchemaSymbolProcessor( name = propertyName, guideAnnotation = property.annotations.firstOrNull() { - it.shortName.getShortName() == Guide::class.java.simpleName + it.shortName.getShortName() == "Guide" }, ) } diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt similarity index 100% rename from firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt rename to firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt similarity index 100% rename from firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt rename to firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt From d7ff3d44edd9e679ebd6f675fce6e544cee1d402 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Wed, 5 Nov 2025 12:31:19 -0800 Subject: [PATCH 10/30] fix imports in schema processor and update firebase-ai/api.txt --- .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 2 -- firebase-ai/api.txt | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index aee193f7302..812777445bd 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -28,8 +28,6 @@ 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.google.firebase.ai.annotations.Generable -import com.google.firebase.ai.annotations.Guide import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec diff --git a/firebase-ai/api.txt b/firebase-ai/api.txt index b5932bf9b0e..2b184a11b79 100644 --- a/firebase-ai/api.txt +++ b/firebase-ai/api.txt @@ -98,6 +98,28 @@ package com.google.firebase.ai { } +package com.google.firebase.ai.annotations { + + @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface Generable { + } + + @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.PROPERTY}) public @interface Guide { + method public abstract String description() default ""; + 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 format; + property public abstract int maxItems; + property public abstract double maximum; + property public abstract int minItems; + property public abstract double minimum; + } + +} + package com.google.firebase.ai.java { public abstract class ChatFutures { From da74532ab21fc528bc9104ff8165522345908ca8 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Wed, 5 Nov 2025 14:08:42 -0800 Subject: [PATCH 11/30] format --- .../com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 812777445bd..f30f7a8c5e6 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -123,9 +123,7 @@ public class SchemaSymbolProcessor( val baseKdoc = extractBaseKdoc(kdocString) val propertyDocs = extractPropertyKdocs(kdocString) val guideClassAnnotation = - type.annotations.firstOrNull() { - it.shortName.getShortName() == "Guide" - } + type.annotations.firstOrNull() { it.shortName.getShortName() == "Guide" } val description = getDescriptionFromAnnotations(guideAnnotation, guideClassAnnotation, description, baseKdoc) val minimum = getDoubleFromAnnotation(guideAnnotation, "minimum") @@ -180,9 +178,7 @@ public class SchemaSymbolProcessor( description = propertyDocs[propertyName], name = propertyName, guideAnnotation = - property.annotations.firstOrNull() { - it.shortName.getShortName() == "Guide" - }, + property.annotations.firstOrNull() { it.shortName.getShortName() == "Guide" }, ) } properties.entries.forEach { From d32ec1f0a037ff03c0de9d170047b6d72dede6c4 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Wed, 5 Nov 2025 14:58:51 -0800 Subject: [PATCH 12/30] format --- firebase-ai/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase-ai/CHANGELOG.md b/firebase-ai/CHANGELOG.md index 21c55237ecf..fa57a850988 100644 --- a/firebase-ai/CHANGELOG.md +++ b/firebase-ai/CHANGELOG.md @@ -1,4 +1,5 @@ # Unreleased + - [feature] Added support for server templates via `TemplateGenerativeModel` and `TemplateImagenModel`. (#7503) From 8624f43ec18a77b3e2a1810a21bc50791b3d5c3b Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 6 Nov 2025 09:32:54 -0800 Subject: [PATCH 13/30] update readme for new artifact name --- firebase-ai-ksp-processor/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase-ai-ksp-processor/README.md b/firebase-ai-ksp-processor/README.md index 07cd50d27d2..6460d9ff410 100644 --- a/firebase-ai-ksp-processor/README.md +++ b/firebase-ai-ksp-processor/README.md @@ -7,7 +7,7 @@ plugins { id("com.google.devtools.ksp") } dependencies { - implementation("com.google.firebase:firebase-ai-ksp-processor:1.0.0") - ksp("com.google.firebase:firebase-ai-ksp-processor:1.0.0") + implementation("com.google.firebase:firebase-ai:") + ksp("com.google.firebase:firebase-ai-processor:1.0.0") } ``` From 334f3a3c8bb33defeefed764dada9ee34dd6ec3b Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Tue, 11 Nov 2025 13:45:21 -0800 Subject: [PATCH 14/30] Add support for enum schema --- .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index f30f7a8c5e6..8327f88eed1 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -22,6 +22,7 @@ import com.google.devtools.ksp.processing.Dependencies import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.processing.Resolver import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.ClassKind import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration @@ -151,17 +152,33 @@ public class SchemaSymbolProcessor( "kotlin.String" -> { builder.addStatement("Schema.string(").indent() } + "kotlin.collections.List" -> { + val listTypeParam = type.arguments.first().type!!.resolve() + val listParamCodeBlock = + generateCodeBlockForSchema(type = listTypeParam, parentType = type) + builder + .addStatement("Schema.array(") + .indent() + .addStatement("items = ") + .add(listParamCodeBlock) + .addStatement(",") + } else -> { - if (className.canonicalName == "kotlin.collections.List") { - val listTypeParam = type.arguments.first().type!!.resolve() - val listParamCodeBlock = - generateCodeBlockForSchema(type = listTypeParam, parentType = type) + if ((type.declaration as? KSClassDeclaration)?.classKind == ClassKind.ENUM_CLASS) { + val enumValues = + (type.declaration as KSClassDeclaration) + .declarations + .filterIsInstance(KSClassDeclaration::class.java) + .map { it.simpleName.asString() } + .toList() builder - .addStatement("Schema.array(") + .addStatement("Schema.enumeration(") + .indent() + .addStatement("values = listOf(") .indent() - .addStatement("items = ") - .add(listParamCodeBlock) - .addStatement(",") + .addStatement(enumValues.joinToString { "\"$it\"" }) + .unindent() + .addStatement("),") } else { builder .addStatement("Schema.obj(") From 8ffa303f83a530dff83846656b11d667f840e310 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Wed, 26 Nov 2025 13:34:44 -0800 Subject: [PATCH 15/30] added JsonSchema type and altered schema generator to output it --- .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 39 +- .../google/firebase/ai/annotations/Guide.kt | 1 + .../com/google/firebase/ai/type/JsonSchema.kt | 493 ++++++++++++++++++ .../com/google/firebase/ai/type/Schema.kt | 4 + 4 files changed, 524 insertions(+), 13 deletions(-) create mode 100644 firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 8327f88eed1..99d9c6083dd 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -34,6 +34,7 @@ import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.ParameterizedTypeName +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.ksp.toClassName @@ -81,7 +82,7 @@ public class SchemaSymbolProcessor( classDeclaration.packageName.asString(), "${classDeclaration.simpleName.asString()}GeneratedSchema", ) - .addImport("com.google.firebase.ai.type", "Schema") + .addImport("com.google.firebase.ai.type", "JsonSchema") .addType( TypeSpec.classBuilder("${classDeclaration.simpleName.asString()}GeneratedSchema") .addAnnotation(Generated::class) @@ -90,7 +91,13 @@ public class SchemaSymbolProcessor( .addProperty( PropertySpec.builder( "SCHEMA", - ClassName("com.google.firebase.ai.type", "Schema"), + ClassName("com.google.firebase.ai.type", "JsonSchema") + .parameterizedBy( + ClassName( + classDeclaration.packageName.asString(), + classDeclaration.simpleName.asString() + ) + ), KModifier.PUBLIC, ) .mutable(false) @@ -132,32 +139,33 @@ public class SchemaSymbolProcessor( val minItems = getIntFromAnnotation(guideAnnotation, "minItems") val maxItems = getIntFromAnnotation(guideAnnotation, "maxItems") val format = getStringFromAnnotation(guideAnnotation, "format") + val pattern = getStringFromAnnotation(guideAnnotation, "pattern") val builder = CodeBlock.builder() when (className.canonicalName) { "kotlin.Int" -> { - builder.addStatement("Schema.integer(").indent() + builder.addStatement("JsonSchema.integer(").indent() } "kotlin.Long" -> { - builder.addStatement("Schema.long(").indent() + builder.addStatement("JsonSchema.long(").indent() } "kotlin.Boolean" -> { - builder.addStatement("Schema.boolean(").indent() + builder.addStatement("JsonSchema.boolean(").indent() } "kotlin.Float" -> { - builder.addStatement("Schema.float(").indent() + builder.addStatement("JsonSchema.float(").indent() } "kotlin.Double" -> { - builder.addStatement("Schema.double(").indent() + builder.addStatement("JsonSchema.double(").indent() } "kotlin.String" -> { - builder.addStatement("Schema.string(").indent() + builder.addStatement("JsonSchema.string(").indent() } "kotlin.collections.List" -> { val listTypeParam = type.arguments.first().type!!.resolve() val listParamCodeBlock = generateCodeBlockForSchema(type = listTypeParam, parentType = type) builder - .addStatement("Schema.array(") + .addStatement("JsonSchema.array(") .indent() .addStatement("items = ") .add(listParamCodeBlock) @@ -172,8 +180,9 @@ public class SchemaSymbolProcessor( .map { it.simpleName.asString() } .toList() builder - .addStatement("Schema.enumeration(") + .addStatement("JsonSchema.enumeration(") .indent() + .addStatement("clazz = ${type.declaration.qualifiedName!!.asString()}::class.java,") .addStatement("values = listOf(") .indent() .addStatement(enumValues.joinToString { "\"$it\"" }) @@ -181,8 +190,9 @@ public class SchemaSymbolProcessor( .addStatement("),") } else { builder - .addStatement("Schema.obj(") + .addStatement("JsonSchema.obj(") .indent() + .addStatement("clazz = ${type.declaration.qualifiedName!!.asString()}::class.java,") .addStatement("properties = mapOf(") .indent() val properties = @@ -229,9 +239,9 @@ public class SchemaSymbolProcessor( "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a List type, minItems and maxItems are not valid parameters to specify in @Guide" ) } - if (format != null && className.canonicalName != "kotlin.String") { + if ((format != null || pattern != null) && className.canonicalName != "kotlin.String") { logger.warn( - "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, format is not a valid parameter to specify in @Guide" + "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, format and pattern are not a valid parameter to specify in @Guide" ) } if (minimum != null) { @@ -249,6 +259,9 @@ public class SchemaSymbolProcessor( if (format != null) { builder.addStatement("format = %S,", format) } + if (pattern != null) { + builder.addStatement("pattern = %S,", pattern) + } builder.addStatement("nullable = %L)", className.isNullable).unindent() return builder.build() } diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt index 1a060721b00..c86237ecec9 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt @@ -25,4 +25,5 @@ public annotation class Guide( public val minItems: Int = -1, public val maxItems: Int = -1, public val format: String = "", + public val pattern: String = "", ) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt new file mode 100644 index 00000000000..b4dce4074d6 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt @@ -0,0 +1,493 @@ +/* + * Copyright 2024 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.type + +import kotlinx.serialization.json.JsonObject + +/** + * Definition of a data type. + * + * These types can be objects, but also primitives and arrays. Represents a select subset of an + * [JsonSchema object](https://json-schema.org/specification). + * + * **Note:** While optional, including a `description` field in your `JsonSchema` is strongly + * encouraged. The more information the model has about what it's expected to generate, the better + * the results. + */ +public class JsonSchema +internal constructor( + public val type: String, + public val clazz: Class, + public val description: String? = null, + public val format: String? = null, + public val pattern: String? = null, + public val nullable: Boolean? = null, + public val enum: List? = null, + public val properties: Map>? = null, + public val required: List? = null, + public val items: JsonSchema<*>? = null, + public val title: String? = null, + public val minItems: Int? = null, + public val maxItems: Int? = null, + public val minimum: Double? = null, + public val maximum: Double? = null, + public val anyOf: List>? = null, +) { + + public companion object { + /** + * Returns a [JsonSchema] representing a boolean value. + * + * @param description An optional description of what the boolean should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun boolean( + description: String? = null, + nullable: Boolean = false, + title: String? = null, + ): JsonSchema = + JsonSchema( + description = description, + nullable = nullable, + type = "BOOLEAN", + title = title, + clazz = Boolean::class.java + ) + + /** + * Returns a [JsonSchema] for a 32-bit signed integer number. + * + * **Important:** This [JsonSchema] provides a hint to the model that it should generate a + * 32-bit integer, but only guarantees that the value will be an integer. Therefore it's + * *possible* that decoding it as an `Int` variable (or `int` in Java) could overflow. + * + * @param description An optional description of what the integer should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numInt") + @JvmOverloads + public fun integer( + description: String? = null, + nullable: Boolean = false, + title: String? = null, + minimum: Double? = null, + maximum: Double? = null, + ): JsonSchema = + JsonSchema( + description = description, + format = "int32", + nullable = nullable, + type = "INTEGER", + title = title, + minimum = minimum, + maximum = maximum, + clazz = Integer::class.java + ) + + /** + * Returns a [JsonSchema] for a 64-bit signed integer number. + * + * @param description An optional description of what the number should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numLong") + @JvmOverloads + public fun long( + description: String? = null, + nullable: Boolean = false, + title: String? = null, + minimum: Double? = null, + maximum: Double? = null, + ): JsonSchema = + JsonSchema( + description = description, + nullable = nullable, + type = "INTEGER", + title = title, + minimum = minimum, + maximum = maximum, + clazz = Long::class.java + ) + + /** + * Returns a [JsonSchema] for a double-precision floating-point number. + * + * @param description An optional description of what the number should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numDouble") + @JvmOverloads + public fun double( + description: String? = null, + nullable: Boolean = false, + title: String? = null, + minimum: Double? = null, + maximum: Double? = null, + ): JsonSchema = + JsonSchema( + description = description, + nullable = nullable, + type = "NUMBER", + title = title, + minimum = minimum, + maximum = maximum, + clazz = Double::class.java + ) + + /** + * Returns a [JsonSchema] for a single-precision floating-point number. + * + * **Important:** This [JsonSchema] provides a hint to the model that it should generate a + * single-precision floating-point number, but only guarantees that the value will be a number. + * Therefore it's *possible* that decoding it as a `Float` variable (or `float` in Java) could + * overflow. + * + * @param description An optional description of what the number should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmName("numFloat") + @JvmOverloads + public fun float( + description: String? = null, + nullable: Boolean = false, + title: String? = null, + minimum: Double? = null, + maximum: Double? = null, + ): JsonSchema = + JsonSchema( + description = description, + nullable = nullable, + type = "NUMBER", + format = "float", + title = title, + minimum = minimum, + maximum = maximum, + clazz = Float::class.java + ) + + /** + * Returns a [JsonSchema] for a string. + * + * @param description An optional description of what the string should contain or represent. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + * @param format An optional pattern that values need to adhere to. + */ + @JvmStatic + @JvmName("str") + @JvmOverloads + public fun string( + description: String? = null, + nullable: Boolean = false, + format: StringFormat? = null, + pattern: String? = null, + title: String? = null, + ): JsonSchema = + JsonSchema( + description = description, + format = format?.value, + nullable = nullable, + type = "STRING", + title = title, + clazz = String::class.java, + pattern = pattern + ) + + /** + * Returns a [JsonSchema] for a complex data type. + * + * This schema instructs the model to produce data of type object, which has keys of type + * `String` and values of type [JsonSchema]. + * + * **Example:** A `city` could be represented with the following object `JsonSchema`. + * + * ``` + * JsonSchema.obj(mapOf( + * "name" to JsonSchema.string(), + * "population" to JsonSchema.integer() + * )) + * ``` + * + * @param properties The map of the object's property names to their [JsonSchema]s. + * @param optionalProperties The list of optional properties. They must correspond to the keys + * provided in the `properties` map. By default it's empty, signaling the model that all + * properties are to be included. + * @param description An optional description of what the object represents. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun obj( + properties: Map>, + optionalProperties: List = emptyList(), + description: String? = null, + nullable: Boolean = false, + title: String? = null, + ): JsonSchema { + if (!properties.keys.containsAll(optionalProperties)) { + throw IllegalArgumentException( + "All optional properties must be present in properties. Missing: ${optionalProperties.minus(properties.keys)}" + ) + } + return JsonSchema( + description = description, + nullable = nullable, + properties = properties, + required = properties.keys.minus(optionalProperties.toSet()).toList(), + type = "OBJECT", + title = title, + clazz = JsonObject::class.java + ) + } + + /** + * Returns a [JsonSchema] for a complex data type. + * + * This schema instructs the model to produce data of type object, which has keys of type + * `String` and values of type [JsonSchema]. + * + * **Example:** A `city` could be represented with the following object `JsonSchema`. + * + * ``` + * JsonSchema.obj(mapOf( + * "name" to JsonSchema.string(), + * "population" to JsonSchema.integer() + * ), + * City::class.java + * ) + * ``` + * + * @param clazz the real class that this schema represents + * @param properties The map of the object's property names to their [JsonSchema]s. + * @param optionalProperties The list of optional properties. They must correspond to the keys + * provided in the `properties` map. By default it's empty, signaling the model that all + * properties are to be included. + * @param description An optional description of what the object represents. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun obj( + clazz: Class, + properties: Map>, + optionalProperties: List = emptyList(), + description: String? = null, + nullable: Boolean = false, + title: String? = null, + ): JsonSchema { + if (!properties.keys.containsAll(optionalProperties)) { + throw IllegalArgumentException( + "All optional properties must be present in properties. Missing: ${optionalProperties.minus(properties.keys)}" + ) + } + return JsonSchema( + description = description, + nullable = nullable, + properties = properties, + required = properties.keys.minus(optionalProperties.toSet()).toList(), + type = "OBJECT", + title = title, + clazz = clazz + ) + } + + /** + * Returns a [JsonSchema] for an array. + * + * @param items The [JsonSchema] of the elements stored in the array. + * @param description An optional description of what the array represents. + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun array( + items: JsonSchema<*>, + description: String? = null, + nullable: Boolean = false, + title: String? = null, + minItems: Int? = null, + maxItems: Int? = null, + ): JsonSchema> = + JsonSchema( + description = description, + nullable = nullable, + items = items, + type = "ARRAY", + title = title, + minItems = minItems, + maxItems = maxItems, + clazz = List::class.java + ) + + /** + * Returns a [JsonSchema] for an enumeration. + * + * For example, the cardinal directions can be represented as: + * ``` + * JsonSchema.enumeration(listOf("north", "east", "south", "west"), "Cardinal directions") + * ``` + * + * @param values The list of valid values for this enumeration + * @param description The description of what the parameter should contain or represent + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun enumeration( + values: List, + description: String? = null, + nullable: Boolean = false, + title: String? = null, + ): JsonSchema = + JsonSchema( + description = description, + format = "enum", + nullable = nullable, + enum = values, + type = "STRING", + title = title, + clazz = String::class.java + ) + + /** + * Returns a [JsonSchema] for an enumeration. + * + * For example, the cardinal directions can be represented as: + * ``` + * JsonSchema.enumeration( + * listOf("north", "east", "south", "west"), + * Direction::class.java, + * "Cardinal directions" + * ) + * ``` + * + * @param clazz the real class that this schema represents + * @param values The list of valid values for this enumeration + * @param description The description of what the parameter should contain or represent + * @param nullable Indicates whether the value can be `null`. Defaults to `false`. + */ + @JvmStatic + @JvmOverloads + public fun enumeration( + clazz: Class, + values: List, + description: String? = null, + nullable: Boolean = false, + title: String? = null, + ): JsonSchema = + JsonSchema( + description = description, + format = "enum", + nullable = nullable, + enum = values, + type = "STRING", + title = title, + clazz = clazz + ) + + /** + * Returns a [JsonSchema] representing a value that must conform to *any* (one of) the provided + * sub-schema. + * + * Example: A field that can hold either a simple userID or a more detailed user object. + * + * ``` + * JsonSchema.anyOf( listOf( JsonSchema.integer(description = "User ID"), JsonSchema.obj( mapOf( + * "userID" to JsonSchema.integer(description = "User ID"), + * "username" to JsonSchema.string(description = "Username") + * ))) + * ``` + * + * @param schemas The list of valid schemas which could be here + */ + @JvmStatic + public fun anyOf(schemas: List>): JsonSchema = + JsonSchema(type = "ANYOF", anyOf = schemas, clazz = String::class.java) + } + + internal fun toInternalJson(): Schema.InternalJson { + val outType = + if (type == "ANYOF" || (type == "STRING" && format == "enum")) { + null + } else { + type.lowercase() + } + + val (outMinimum, outMaximum) = + if (outType == "integer" && format == "int32") { + (minimum ?: Integer.MIN_VALUE.toDouble()) to (maximum ?: Integer.MAX_VALUE.toDouble()) + } else { + minimum to maximum + } + + val outFormat = + if ( + (outType == "integer" && format == "int32") || + (outType == "number" && format == "float") || + format == "enum" + ) { + null + } else { + format + } + + if (nullable == true) { + return Schema.InternalJsonNullable( + outType?.let { listOf(it, "null") }, + description, + outFormat, + pattern, + enum?.let { + buildList { + addAll(it) + add("null") + } + }, + properties?.mapValues { it.value.toInternalJson() }, + required, + items?.toInternalJson(), + title, + minItems, + maxItems, + outMinimum, + outMaximum, + anyOf?.map { it.toInternalJson() }, + ) + } + return Schema.InternalJsonNonNull( + outType, + description, + outFormat, + pattern, + enum, + properties?.mapValues { it.value.toInternalJson() }, + required, + items?.toInternalJson(), + title, + minItems, + maxItems, + outMinimum, + outMaximum, + anyOf?.map { it.toInternalJson() }, + ) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Schema.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Schema.kt index 1dfa4ddecb0..9f728adbbd4 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Schema.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Schema.kt @@ -378,6 +378,7 @@ internal constructor( outType?.let { listOf(it, "null") }, description, outFormat, + null, enum?.let { buildList { addAll(it) @@ -399,6 +400,7 @@ internal constructor( outType, description, outFormat, + null, enum, properties?.mapValues { it.value.toInternalJson() }, required, @@ -437,6 +439,7 @@ internal constructor( val type: String? = null, val description: String? = null, val format: String? = null, + val pattern: String? = null, val enum: List? = null, val properties: Map? = null, val required: List? = null, @@ -454,6 +457,7 @@ internal constructor( val type: List? = null, val description: String? = null, val format: String? = null, + val pattern: String? = null, val enum: List? = null, val properties: Map? = null, val required: List? = null, From 74eece1e42d4e27a1f670c1c55dca234ec580177 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Thu, 4 Dec 2025 11:41:43 -0800 Subject: [PATCH 16/30] introduced new auto function declaration type and auto function calling --- .../kotlin/com/google/firebase/ai/Chat.kt | 125 ++++++++++++------ .../com/google/firebase/ai/GenerativeModel.kt | 44 ++++++ .../ai/type/AutoFunctionDeclaration.kt | 55 ++++++++ .../firebase/ai/type/FunctionDeclaration.kt | 6 +- .../com/google/firebase/ai/type/JsonSchema.kt | 43 +++--- .../com/google/firebase/ai/type/Tool.kt | 22 ++- 6 files changed, 225 insertions(+), 70 deletions(-) create mode 100644 firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt index 73d304d3885..47c3a8c49ae 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt @@ -18,17 +18,18 @@ package com.google.firebase.ai import android.graphics.Bitmap import com.google.firebase.ai.type.Content +import com.google.firebase.ai.type.FunctionCallPart +import com.google.firebase.ai.type.FunctionResponsePart import com.google.firebase.ai.type.GenerateContentResponse -import com.google.firebase.ai.type.ImagePart -import com.google.firebase.ai.type.InlineDataPart import com.google.firebase.ai.type.InvalidStateException import com.google.firebase.ai.type.TextPart import com.google.firebase.ai.type.content import java.util.LinkedList import java.util.concurrent.Semaphore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.transform /** * Representation of a multi-turn interaction with a model. @@ -51,25 +52,36 @@ public class Chat( private var lock = Semaphore(1) /** - * Sends a message using the provided [prompt]; automatically providing the existing [history] as - * context. + * Sends a message using the provided [inputPrompt]; automatically providing the existing + * [history] as context. * * If successful, the message and response will be added to the [history]. If unsuccessful, * [history] will remain unchanged. * - * @param prompt The input that, together with the history, will be given to the model as the + * @param inputPrompt The input that, together with the history, will be given to the model as the * prompt. - * @throws InvalidStateException if [prompt] is not coming from the 'user' role. + * @throws InvalidStateException if [inputPrompt] is not coming from the 'user' role. * @throws InvalidStateException if the [Chat] instance has an active request. */ - public suspend fun sendMessage(prompt: Content): GenerateContentResponse { - prompt.assertComesFromUser() + public suspend fun sendMessage(inputPrompt: Content): GenerateContentResponse { + inputPrompt.assertComesFromUser() attemptLock() + var response: GenerateContentResponse + var prompt = inputPrompt try { - val fullPrompt = history + prompt - val response = model.generateContent(fullPrompt.first(), *fullPrompt.drop(1).toTypedArray()) - history.add(prompt) - history.add(response.candidates.first().content) + while (true) { + response = model.generateContent(listOf(*history.toTypedArray(), prompt)) + val responsePart = response.candidates.first().content.parts.first() + + history.add(prompt) + history.add(response.candidates.first().content) + if (responsePart is FunctionCallPart) { + val output = model.executeFunction(responsePart) + prompt = Content("function", listOf(FunctionResponsePart(responsePart.name, output))) + } else { + break + } + } return response } finally { lock.release() @@ -130,9 +142,8 @@ public class Chat( val fullPrompt = history + prompt val flow = model.generateContentStream(fullPrompt.first(), *fullPrompt.drop(1).toTypedArray()) - val bitmaps = LinkedList() - val inlineDataParts = LinkedList() - val text = StringBuilder() + val tempHistory = LinkedList() + tempHistory.add(prompt) /** * TODO: revisit when images and inline data are returned. This will cause issues with how @@ -140,33 +151,11 @@ public class Chat( * represented as image/text */ return flow - .onEach { - for (part in it.candidates.first().content.parts) { - when (part) { - is TextPart -> text.append(part.text) - is ImagePart -> bitmaps.add(part.image) - is InlineDataPart -> inlineDataParts.add(part) - } - } - } + .transform { response -> automaticFunctionExecutingTransform(this, tempHistory, response) } .onCompletion { lock.release() if (it == null) { - val content = - content("model") { - for (bitmap in bitmaps) { - image(bitmap) - } - for (inlineDataPart in inlineDataParts) { - inlineData(inlineDataPart.inlineData, inlineDataPart.mimeType) - } - if (text.isNotBlank()) { - text(text.toString()) - } - } - - history.add(prompt) - history.add(content) + history.addAll(tempHistory) } } } @@ -209,6 +198,62 @@ public class Chat( return sendMessageStream(content) } + private suspend fun automaticFunctionExecutingTransform( + transformer: FlowCollector, + tempHistory: LinkedList, + response: GenerateContentResponse + ) { + for (part in response.candidates.first().content.parts) { + when (part) { + is TextPart -> { + transformer.emit(response) + addTextToHistory(tempHistory, part) + } + is FunctionCallPart -> { + val functionCall = + response.candidates.first().content.parts.first { it is FunctionCallPart } + as FunctionCallPart + val output = model.executeFunction(functionCall) + val functionResponse = + Content("function", listOf(FunctionResponsePart(functionCall.name, output))) + tempHistory.add(response.candidates.first().content) + tempHistory.add(functionResponse) + model + .generateContentStream(listOf(*history.toTypedArray(), *tempHistory.toTypedArray())) + .collect { automaticFunctionExecutingTransform(transformer, tempHistory, it) } + } + else -> { + transformer.emit(response) + tempHistory.add(Content("model", listOf(part))) + } + } + } + } + + private fun addTextToHistory(tempHistory: LinkedList, textPart: TextPart) { + val lastContent = tempHistory.lastOrNull() + if (lastContent?.role == "model" && lastContent.parts.any { it is TextPart }) { + tempHistory.removeLast() + val editedContent = + Content( + "model", + lastContent.parts.map { + when (it) { + is TextPart -> { + TextPart(it.text + textPart.text) + } + else -> { + it + } + } + } + ) + tempHistory.add(editedContent) + return + } + tempHistory.add(Content("model", listOf(textPart))) + } + private fun Content.assertComesFromUser() { if (role !in listOf("user", "function")) { throw InvalidStateException("Chat prompts should come from the 'user' or 'function' role.") diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt index 45aa1e567e3..55f81b79d19 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt @@ -22,10 +22,12 @@ import com.google.firebase.ai.common.APIController import com.google.firebase.ai.common.AppCheckHeaderProvider import com.google.firebase.ai.common.CountTokensRequest import com.google.firebase.ai.common.GenerateContentRequest +import com.google.firebase.ai.type.AutoFunctionDeclaration 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.FunctionCallPart import com.google.firebase.ai.type.GenerateContentResponse import com.google.firebase.ai.type.GenerationConfig import com.google.firebase.ai.type.GenerativeBackend @@ -45,6 +47,11 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.map import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.serializerOrNull /** * Represents a multimodal model (like Gemini), capable of generating content based on various input @@ -266,6 +273,43 @@ internal constructor( return countTokens(content { image(prompt) }) } + @OptIn(InternalSerializationApi::class) + internal suspend fun executeFunction(call: FunctionCallPart): JsonObject { + if (tools == null) { + throw RuntimeException("No registered tools") + } + val tool = tools.flatMap { it.autoFunctionDeclarations?.filterNotNull() ?: emptyList() } + val declaration = + tool.firstOrNull() { it.name == call.name } + ?: throw RuntimeException("No registered function named ${call.name}") + return executeFunction( + declaration as AutoFunctionDeclaration, + call.args["param"].toString() + ) + } + + @OptIn(InternalSerializationApi::class) + internal suspend fun executeFunction( + functionDeclaration: AutoFunctionDeclaration, + parameter: String + ): JsonObject { + val inputDeserializer = + functionDeclaration.inputSchema.clazz.serializerOrNull() + ?: throw RuntimeException( + "Function input type ${functionDeclaration.inputSchema.clazz.qualifiedName} is not @Serializable" + ) + val input = Json.decodeFromString(inputDeserializer, parameter) + val functionReference = + functionDeclaration.functionReference + ?: throw RuntimeException("Function reference for ${functionDeclaration.name} is missing") + val output = functionReference.invoke(input) + val outputSerializer = functionDeclaration.outputSchema?.clazz?.serializerOrNull() + if (outputSerializer != null) { + return Json.encodeToJsonElement(outputSerializer, output).jsonObject + } + return output as JsonObject + } + @OptIn(ExperimentalSerializationApi::class) private fun constructRequest(vararg prompt: Content) = GenerateContentRequest( diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt new file mode 100644 index 00000000000..623f73d4ee2 --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt @@ -0,0 +1,55 @@ +package com.google.firebase.ai.type + +import kotlinx.serialization.json.JsonObject + +public class AutoFunctionDeclaration +internal constructor( + public val name: String, + public val description: String, + public val inputSchema: JsonSchema, + public val outputSchema: JsonSchema? = null, + public val functionReference: (suspend (I) -> O)? = null +) { + public companion object { + public fun create( + functionName: String, + description: String, + inputSchema: JsonSchema, + outputSchema: JsonSchema, + functionReference: ((I) -> O)? = null + ): AutoFunctionDeclaration { + return AutoFunctionDeclaration( + functionName, + description, + inputSchema, + outputSchema, + functionReference + ) + } + + public fun create( + functionName: String, + inputSchema: JsonSchema, + description: String, + functionReference: ((I) -> JsonObject)? = null + ): AutoFunctionDeclaration { + return AutoFunctionDeclaration( + functionName, + description, + inputSchema, + null, + functionReference + ) + } + } + + internal fun toInternal(): FunctionDeclaration.Internal { + return FunctionDeclaration.Internal( + name, + description, + null, + JsonSchema.obj(mapOf("param" to inputSchema)).toInternalJson(), + outputSchema?.toInternalJson() + ) + } +} diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionDeclaration.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionDeclaration.kt index 65b753efda7..7c7eb338ead 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionDeclaration.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/FunctionDeclaration.kt @@ -61,12 +61,14 @@ public class FunctionDeclaration( internal val schema: Schema = Schema.obj(properties = parameters, optionalProperties = optionalParameters, nullable = false) - internal fun toInternal() = Internal(name, description, schema.toInternalOpenApi()) + internal fun toInternal() = Internal(name, description, schema.toInternalOpenApi(), null, null) @Serializable internal data class Internal( val name: String, val description: String, - val parameters: Schema.InternalOpenAPI + val parameters: Schema.InternalOpenAPI?, + val parametersJsonSchema: Schema.InternalJson?, + val responseJsonSchema: Schema.InternalJson?, ) } diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt index b4dce4074d6..c59bb4a673b 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/JsonSchema.kt @@ -16,6 +16,7 @@ package com.google.firebase.ai.type +import kotlin.reflect.KClass import kotlinx.serialization.json.JsonObject /** @@ -28,10 +29,10 @@ import kotlinx.serialization.json.JsonObject * encouraged. The more information the model has about what it's expected to generate, the better * the results. */ -public class JsonSchema +public class JsonSchema internal constructor( public val type: String, - public val clazz: Class, + public val clazz: KClass, public val description: String? = null, public val format: String? = null, public val pattern: String? = null, @@ -67,7 +68,7 @@ internal constructor( nullable = nullable, type = "BOOLEAN", title = title, - clazz = Boolean::class.java + clazz = Boolean::class ) /** @@ -98,7 +99,7 @@ internal constructor( title = title, minimum = minimum, maximum = maximum, - clazz = Integer::class.java + clazz = Integer::class ) /** @@ -124,7 +125,7 @@ internal constructor( title = title, minimum = minimum, maximum = maximum, - clazz = Long::class.java + clazz = Long::class ) /** @@ -150,7 +151,7 @@ internal constructor( title = title, minimum = minimum, maximum = maximum, - clazz = Double::class.java + clazz = Double::class ) /** @@ -182,7 +183,7 @@ internal constructor( title = title, minimum = minimum, maximum = maximum, - clazz = Float::class.java + clazz = Float::class ) /** @@ -199,7 +200,6 @@ internal constructor( description: String? = null, nullable: Boolean = false, format: StringFormat? = null, - pattern: String? = null, title: String? = null, ): JsonSchema = JsonSchema( @@ -208,8 +208,7 @@ internal constructor( nullable = nullable, type = "STRING", title = title, - clazz = String::class.java, - pattern = pattern + clazz = String::class ) /** @@ -255,7 +254,7 @@ internal constructor( required = properties.keys.minus(optionalProperties.toSet()).toList(), type = "OBJECT", title = title, - clazz = JsonObject::class.java + clazz = JsonObject::class ) } @@ -272,12 +271,12 @@ internal constructor( * "name" to JsonSchema.string(), * "population" to JsonSchema.integer() * ), - * City::class.java + * City::class * ) * ``` * - * @param clazz the real class that this schema represents * @param properties The map of the object's property names to their [JsonSchema]s. + * @param clazz the real class that this schema represents * @param optionalProperties The list of optional properties. They must correspond to the keys * provided in the `properties` map. By default it's empty, signaling the model that all * properties are to be included. @@ -286,9 +285,9 @@ internal constructor( */ @JvmStatic @JvmOverloads - public fun obj( - clazz: Class, + public fun obj( properties: Map>, + clazz: KClass, optionalProperties: List = emptyList(), description: String? = null, nullable: Boolean = false, @@ -335,7 +334,7 @@ internal constructor( title = title, minItems = minItems, maxItems = maxItems, - clazz = List::class.java + clazz = List::class ) /** @@ -365,7 +364,7 @@ internal constructor( enum = values, type = "STRING", title = title, - clazz = String::class.java + clazz = String::class ) /** @@ -375,21 +374,21 @@ internal constructor( * ``` * JsonSchema.enumeration( * listOf("north", "east", "south", "west"), - * Direction::class.java, + * Direction::class, * "Cardinal directions" * ) * ``` * - * @param clazz the real class that this schema represents * @param values The list of valid values for this enumeration + * @param clazz the real class that this schema represents * @param description The description of what the parameter should contain or represent * @param nullable Indicates whether the value can be `null`. Defaults to `false`. */ @JvmStatic @JvmOverloads - public fun enumeration( - clazz: Class, + public fun enumeration( values: List, + clazz: KClass, description: String? = null, nullable: Boolean = false, title: String? = null, @@ -421,7 +420,7 @@ internal constructor( */ @JvmStatic public fun anyOf(schemas: List>): JsonSchema = - JsonSchema(type = "ANYOF", anyOf = schemas, clazz = String::class.java) + JsonSchema(type = "ANYOF", anyOf = schemas, clazz = String::class) } internal fun toInternalJson(): Schema.InternalJson { diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt index 43a66a10d62..8b3e4329faf 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt @@ -27,6 +27,7 @@ public class Tool @OptIn(PublicPreviewAPI::class) internal constructor( internal val functionDeclarations: List?, + internal val autoFunctionDeclarations: List>?, internal val googleSearch: GoogleSearch?, internal val codeExecution: JsonObject?, @property:PublicPreviewAPI internal val urlContext: UrlContext?, @@ -35,7 +36,10 @@ internal constructor( @OptIn(PublicPreviewAPI::class) internal fun toInternal() = Internal( - functionDeclarations?.map { it.toInternal() } ?: emptyList(), + buildList { + functionDeclarations?.let { addAll(it.map { it.toInternal() }) } + autoFunctionDeclarations?.let { addAll(it.map { it.toInternal() }) } + }, googleSearch = this.googleSearch?.toInternal(), codeExecution = this.codeExecution, urlContext = this.urlContext?.toInternal() @@ -53,7 +57,9 @@ internal constructor( public companion object { @OptIn(PublicPreviewAPI::class) - private val codeExecutionInstance by lazy { Tool(null, null, JsonObject(emptyMap()), null) } + private val codeExecutionInstance by lazy { + Tool(null, null, null, JsonObject(emptyMap()), null) + } /** * Creates a [Tool] instance that provides the model with access to the [functionDeclarations]. @@ -61,8 +67,12 @@ internal constructor( * @param functionDeclarations The list of functions that this tool allows the model access to. */ @JvmStatic - public fun functionDeclarations(functionDeclarations: List): Tool { - @OptIn(PublicPreviewAPI::class) return Tool(functionDeclarations, null, null, null) + public fun functionDeclarations( + functionDeclarations: List? = null, + autoFunctionDeclarations: List>? = null + ): Tool { + @OptIn(PublicPreviewAPI::class) + return Tool(functionDeclarations, autoFunctionDeclarations, null, null, null) } /** Creates a [Tool] instance that allows the model to use code execution. */ @@ -82,7 +92,7 @@ internal constructor( @PublicPreviewAPI @JvmStatic public fun urlContext(urlContext: UrlContext = UrlContext()): Tool { - return Tool(null, null, null, urlContext) + return Tool(null, null, null, null, urlContext) } /** @@ -103,7 +113,7 @@ internal constructor( */ @JvmStatic public fun googleSearch(googleSearch: GoogleSearch = GoogleSearch()): Tool { - @OptIn(PublicPreviewAPI::class) return Tool(null, googleSearch, null, null) + @OptIn(PublicPreviewAPI::class) return Tool(null, null, googleSearch, null, null) } } } From 5de85e9d28a682fad9f004671f0caf769893a8a5 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Mon, 8 Dec 2025 16:04:09 -0800 Subject: [PATCH 17/30] update api.txt and schema generation type safety --- .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 4 +- firebase-ai/api.txt | 182 +++++++++++++++++- .../ai/type/AutoFunctionDeclaration.kt | 2 +- 3 files changed, 182 insertions(+), 6 deletions(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 99d9c6083dd..40cba5f21dc 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -182,7 +182,7 @@ public class SchemaSymbolProcessor( builder .addStatement("JsonSchema.enumeration(") .indent() - .addStatement("clazz = ${type.declaration.qualifiedName!!.asString()}::class.java,") + .addStatement("clazz = ${type.declaration.qualifiedName!!.asString()}::class,") .addStatement("values = listOf(") .indent() .addStatement(enumValues.joinToString { "\"$it\"" }) @@ -192,7 +192,7 @@ public class SchemaSymbolProcessor( builder .addStatement("JsonSchema.obj(") .indent() - .addStatement("clazz = ${type.declaration.qualifiedName!!.asString()}::class.java,") + .addStatement("clazz = ${type.declaration.qualifiedName!!.asString()}::class,") .addStatement("properties = mapOf(") .indent() val properties = diff --git a/firebase-ai/api.txt b/firebase-ai/api.txt index 1e6cc6d6af5..23c5117122e 100644 --- a/firebase-ai/api.txt +++ b/firebase-ai/api.txt @@ -5,7 +5,7 @@ package com.google.firebase.ai { ctor public Chat(com.google.firebase.ai.GenerativeModel model, java.util.List history = java.util.ArrayList()); method public java.util.List getHistory(); method public suspend Object? sendMessage(android.graphics.Bitmap prompt, kotlin.coroutines.Continuation); - method public suspend Object? sendMessage(com.google.firebase.ai.type.Content prompt, kotlin.coroutines.Continuation); + method public suspend Object? sendMessage(com.google.firebase.ai.type.Content inputPrompt, kotlin.coroutines.Continuation); method public suspend Object? sendMessage(String prompt, kotlin.coroutines.Continuation); method public kotlinx.coroutines.flow.Flow sendMessageStream(android.graphics.Bitmap prompt); method public kotlinx.coroutines.flow.Flow sendMessageStream(com.google.firebase.ai.type.Content prompt); @@ -110,12 +110,14 @@ package com.google.firebase.ai.annotations { method public abstract double maximum() default -1.0; method public abstract int minItems() default -1; method public abstract double minimum() default -1.0; + method public abstract String pattern() default ""; property public abstract String description; property public abstract String format; property public abstract int maxItems; property public abstract double maximum; property public abstract int minItems; property public abstract double minimum; + property public abstract String pattern; } } @@ -240,6 +242,25 @@ package com.google.firebase.ai.type { ctor public AudioTranscriptionConfig(); } + public final class AutoFunctionDeclaration { + method public String getDescription(); + method public kotlin.jvm.functions.Function2,java.lang.Object?>? getFunctionReference(); + method public com.google.firebase.ai.type.JsonSchema getInputSchema(); + method public String getName(); + method public com.google.firebase.ai.type.JsonSchema? getOutputSchema(); + property public final String description; + property public final kotlin.jvm.functions.Function2,java.lang.Object?>? functionReference; + property public final com.google.firebase.ai.type.JsonSchema inputSchema; + property public final String name; + property public final com.google.firebase.ai.type.JsonSchema? outputSchema; + field public static final com.google.firebase.ai.type.AutoFunctionDeclaration.Companion Companion; + } + + public static final class AutoFunctionDeclaration.Companion { + method public com.google.firebase.ai.type.AutoFunctionDeclaration create(String functionName, String description, com.google.firebase.ai.type.JsonSchema inputSchema, com.google.firebase.ai.type.JsonSchema outputSchema, kotlin.jvm.functions.Function1? functionReference = null); + method public com.google.firebase.ai.type.AutoFunctionDeclaration create(String functionName, String description, com.google.firebase.ai.type.JsonSchema inputSchema, kotlin.jvm.functions.Function1? functionReference = null); + } + public final class BlockReason { method public String getName(); method public int getOrdinal(); @@ -897,6 +918,161 @@ package com.google.firebase.ai.type { public final class InvalidStateException extends com.google.firebase.ai.type.FirebaseAIException { } + public final class JsonSchema { + method public static com.google.firebase.ai.type.JsonSchema anyOf(java.util.List> schemas); + method public static com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items); + method public static com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null); + method public static com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false, String? title = null, Integer? minItems = null); + method public static com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false, String? title = null, Integer? minItems = null, Integer? maxItems = null); + method public static com.google.firebase.ai.type.JsonSchema boolean(); + method public static com.google.firebase.ai.type.JsonSchema boolean(String? description = null); + method public static com.google.firebase.ai.type.JsonSchema boolean(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema boolean(String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, String? description = null); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz, String? description = null); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz, String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz, String? description = null, boolean nullable = false, String? title = null); + method public java.util.List>? getAnyOf(); + method public kotlin.reflect.KClass getClazz(); + method public String? getDescription(); + method public java.util.List? getEnum(); + method public String? getFormat(); + method public com.google.firebase.ai.type.JsonSchema? getItems(); + method public Integer? getMaxItems(); + method public Double? getMaximum(); + method public Integer? getMinItems(); + method public Double? getMinimum(); + method public Boolean? getNullable(); + method public String? getPattern(); + method public java.util.Map>? getProperties(); + method public java.util.List? getRequired(); + method public String? getTitle(); + method public String getType(); + method public static com.google.firebase.ai.type.JsonSchema numDouble(); + method public static com.google.firebase.ai.type.JsonSchema numDouble(String? description = null); + method public static com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public static com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public static com.google.firebase.ai.type.JsonSchema numFloat(); + method public static com.google.firebase.ai.type.JsonSchema numFloat(String? description = null); + method public static com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public static com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public static com.google.firebase.ai.type.JsonSchema numInt(); + method public static com.google.firebase.ai.type.JsonSchema numInt(String? description = null); + method public static com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public static com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public static com.google.firebase.ai.type.JsonSchema numLong(); + method public static com.google.firebase.ai.type.JsonSchema numLong(String? description = null); + method public static com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public static com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList()); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList(), String? description = null); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList()); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList(), String? description = null); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false, String? title = null); + method public static com.google.firebase.ai.type.JsonSchema str(); + method public static com.google.firebase.ai.type.JsonSchema str(String? description = null); + method public static com.google.firebase.ai.type.JsonSchema str(String? description = null, boolean nullable = false); + method public static com.google.firebase.ai.type.JsonSchema str(String? description = null, boolean nullable = false, com.google.firebase.ai.type.StringFormat? format = null); + method public static com.google.firebase.ai.type.JsonSchema str(String? description = null, boolean nullable = false, com.google.firebase.ai.type.StringFormat? format = null, String? title = null); + property public final java.util.List>? anyOf; + property public final kotlin.reflect.KClass clazz; + property public final String? description; + property public final java.util.List? enum; + property public final String? format; + property public final com.google.firebase.ai.type.JsonSchema? items; + property public final Integer? maxItems; + property public final Double? maximum; + property public final Integer? minItems; + property public final Double? minimum; + property public final Boolean? nullable; + property public final String? pattern; + property public final java.util.Map>? properties; + property public final java.util.List? required; + property public final String? title; + property public final String type; + field public static final com.google.firebase.ai.type.JsonSchema.Companion Companion; + } + + public static final class JsonSchema.Companion { + method public com.google.firebase.ai.type.JsonSchema anyOf(java.util.List> schemas); + method public com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items); + method public com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null); + method public com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false, String? title = null, Integer? minItems = null); + method public com.google.firebase.ai.type.JsonSchema> array(com.google.firebase.ai.type.JsonSchema items, String? description = null, boolean nullable = false, String? title = null, Integer? minItems = null, Integer? maxItems = null); + method public com.google.firebase.ai.type.JsonSchema boolean(); + method public com.google.firebase.ai.type.JsonSchema boolean(String? description = null); + method public com.google.firebase.ai.type.JsonSchema boolean(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema boolean(String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, String? description = null); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz, String? description = null); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz, String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema enumeration(java.util.List values, kotlin.reflect.KClass clazz, String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema numDouble(); + method public com.google.firebase.ai.type.JsonSchema numDouble(String? description = null); + method public com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public com.google.firebase.ai.type.JsonSchema numDouble(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public com.google.firebase.ai.type.JsonSchema numFloat(); + method public com.google.firebase.ai.type.JsonSchema numFloat(String? description = null); + method public com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public com.google.firebase.ai.type.JsonSchema numFloat(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public com.google.firebase.ai.type.JsonSchema numInt(); + method public com.google.firebase.ai.type.JsonSchema numInt(String? description = null); + method public com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public com.google.firebase.ai.type.JsonSchema numInt(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public com.google.firebase.ai.type.JsonSchema numLong(); + method public com.google.firebase.ai.type.JsonSchema numLong(String? description = null); + method public com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null); + method public com.google.firebase.ai.type.JsonSchema numLong(String? description = null, boolean nullable = false, String? title = null, Double? minimum = null, Double? maximum = null); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList()); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList(), String? description = null); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList()); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList(), String? description = null); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema obj(java.util.Map> properties, kotlin.reflect.KClass clazz, java.util.List optionalProperties = emptyList(), String? description = null, boolean nullable = false, String? title = null); + method public com.google.firebase.ai.type.JsonSchema str(); + method public com.google.firebase.ai.type.JsonSchema str(String? description = null); + method public com.google.firebase.ai.type.JsonSchema str(String? description = null, boolean nullable = false); + method public com.google.firebase.ai.type.JsonSchema str(String? description = null, boolean nullable = false, com.google.firebase.ai.type.StringFormat? format = null); + method public com.google.firebase.ai.type.JsonSchema str(String? description = null, boolean nullable = false, com.google.firebase.ai.type.StringFormat? format = null, String? title = null); + } + @com.google.firebase.ai.type.PublicPreviewAPI public final class LiveAudioConversationConfig { field public static final com.google.firebase.ai.type.LiveAudioConversationConfig.Companion Companion; } @@ -1319,7 +1495,7 @@ package com.google.firebase.ai.type { public final class Tool { method public static com.google.firebase.ai.type.Tool codeExecution(); - method public static com.google.firebase.ai.type.Tool functionDeclarations(java.util.List functionDeclarations); + method public static com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null, java.util.List>? autoFunctionDeclarations = null); method public static com.google.firebase.ai.type.Tool googleSearch(com.google.firebase.ai.type.GoogleSearch googleSearch = com.google.firebase.ai.type.GoogleSearch()); method @com.google.firebase.ai.type.PublicPreviewAPI public static com.google.firebase.ai.type.Tool urlContext(com.google.firebase.ai.type.UrlContext urlContext = com.google.firebase.ai.type.UrlContext()); field public static final com.google.firebase.ai.type.Tool.Companion Companion; @@ -1327,7 +1503,7 @@ package com.google.firebase.ai.type { public static final class Tool.Companion { method public com.google.firebase.ai.type.Tool codeExecution(); - method public com.google.firebase.ai.type.Tool functionDeclarations(java.util.List functionDeclarations); + method public com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null, java.util.List>? autoFunctionDeclarations = null); method public com.google.firebase.ai.type.Tool googleSearch(com.google.firebase.ai.type.GoogleSearch googleSearch = com.google.firebase.ai.type.GoogleSearch()); method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.type.Tool urlContext(com.google.firebase.ai.type.UrlContext urlContext = com.google.firebase.ai.type.UrlContext()); } diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt index 623f73d4ee2..59c650214fc 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt @@ -29,8 +29,8 @@ internal constructor( public fun create( functionName: String, - inputSchema: JsonSchema, description: String, + inputSchema: JsonSchema, functionReference: ((I) -> JsonObject)? = null ): AutoFunctionDeclaration { return AutoFunctionDeclaration( From b9a59d380c6bccfedc20ce2957f91524342e15f6 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Mon, 8 Dec 2025 16:05:23 -0800 Subject: [PATCH 18/30] format --- firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt index b234498486b..f06eb448d92 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt @@ -22,7 +22,6 @@ import com.google.firebase.ai.type.FunctionCallPart import com.google.firebase.ai.type.FunctionResponsePart import com.google.firebase.ai.type.GenerateContentResponse import com.google.firebase.ai.type.InvalidStateException -import com.google.firebase.ai.type.Part import com.google.firebase.ai.type.TextPart import com.google.firebase.ai.type.content import java.util.LinkedList From fb9339981151e729d12832f2e496f20f925d3951 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Fri, 12 Dec 2025 15:16:04 -0800 Subject: [PATCH 19/30] format, add documentation, and generate api.txt file --- .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 15 +++++++-------- .../google/firebase/ai/annotations/Generable.kt | 5 +++++ .../com/google/firebase/ai/annotations/Guide.kt | 11 +++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 40cba5f21dc..71211448ebc 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -50,16 +50,13 @@ public class SchemaSymbolProcessor( resolver .getSymbolsWithAnnotation("com.google.firebase.ai.annotations.Generable") .filterIsInstance() - .map { it to SchemaSymbolProcessorVisitor(it, resolver) } - .forEach { it.second.visitClassDeclaration(it.first, Unit) } + .map { it to SchemaSymbolProcessorVisitor() } + .forEach { (klass, visitor) -> visitor.visitClassDeclaration(klass, Unit) } return emptyList() } - private inner class SchemaSymbolProcessorVisitor( - private val klass: KSClassDeclaration, - private val resolver: Resolver, - ) : KSVisitorVoid() { + private inner class SchemaSymbolProcessorVisitor() : KSVisitorVoid() { private val numberTypes = setOf("kotlin.Int", "kotlin.Long", "kotlin.Double", "kotlin.Float") private val baseKdocRegex = Regex("^\\s*(.*?)((@\\w* .*)|\\z)", RegexOption.DOT_MATCHES_ALL) private val propertyKdocRegex = @@ -228,7 +225,8 @@ public class SchemaSymbolProcessor( } if ((minimum != null || maximum != null) && !numberTypes.contains(className.canonicalName)) { logger.warn( - "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a number type, minimum and maximum are not valid parameters to specify in @Guide" + "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a number type, " + + "minimum and maximum are not valid parameters to specify in @Guide" ) } if ( @@ -236,7 +234,8 @@ public class SchemaSymbolProcessor( className.canonicalName != "kotlin.collections.List" ) { logger.warn( - "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a List type, minItems and maxItems are not valid parameters to specify in @Guide" + "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a List type, " + + "minItems and maxItems are not valid parameters to specify in @Guide" ) } if ((format != null || pattern != null) && className.canonicalName != "kotlin.String") { diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt index b4a5e652ae5..fe217272bbd 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt @@ -16,6 +16,11 @@ package com.google.firebase.ai.annotations +/** + * This annotation is used with the firebase-ai-ksp-processor plugin to generate JsonSchema that + * match an existing kotlin class structure. For more info see: + * https://github.com/firebase/firebase-android-sdk/blob/main/firebase-ai-ksp-processor/README.md + */ @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) public annotation class Generable diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt index c86237ecec9..605f163d955 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt @@ -16,6 +16,17 @@ package com.google.firebase.ai.annotations +/** + * This annotation is used with the firebase-ai-ksp-processor plugin to provide extra information on + * generated classes and fields. + * @property description a description of the field + * @property minimum the minimum value (exclusive) which the numeric field may contain + * @property maximum the maximum value (exclusive) which the numeric field may contain + * @property minItems the minimum number of items in a list + * @property maxItems the maximum number of items in a list + * @property format the format that a field must conform to + * @property pattern the regular expression that a string field must conform to + */ @Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.SOURCE) public annotation class Guide( From a6493b19f72fc1ed652bdf71efe7e54ae666167b Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Fri, 12 Dec 2025 15:55:16 -0800 Subject: [PATCH 20/30] add AutoFunctionDeclaration documentation --- .../ai/type/AutoFunctionDeclaration.kt | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt index 59c650214fc..9b85ed8312f 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/AutoFunctionDeclaration.kt @@ -1,7 +1,46 @@ +/* + * 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. + */ + package com.google.firebase.ai.type import kotlinx.serialization.json.JsonObject +/** + * Defines a function that the model can use as a tool. Including a function references to enable + * automatic function calling. + * + * When generating responses, the model might need external information or require the application + * to perform an action. `AutoFunctionDeclaration` provides the necessary information for the model + * to create a [FunctionCallPart], which instructs the client to execute the corresponding function. + * The client then sends the result back to the model as a [FunctionResponsePart]. + * + * For example + * + * ``` + * val getExchangeRate = AutoFunctionDeclaration.create( + * name = "getExchangeRate", + * description = "Get the exchange rate for currencies between countries.", + * inputSchema = CurrencyRequest.schema, + * outputSchema = CurrencyResponse.schema, + * ) { + * // make an api request to convert currencies and return the result + * } + * ``` + * @see JsonSchema + */ public class AutoFunctionDeclaration internal constructor( public val name: String, @@ -11,6 +50,16 @@ internal constructor( public val functionReference: (suspend (I) -> O)? = null ) { public companion object { + + /** + * Create a strongly typed function declaration with an associated function reference. + * + * @param functionName the name of the function (to the model) + * @param description the description of the function + * @param inputSchema the object the model must provide to you as input + * @param outputSchema the type that will be return to the model when the function is executed + * @param functionReference the function that will be executed when requested by the model. + */ public fun create( functionName: String, description: String, @@ -27,6 +76,15 @@ internal constructor( ) } + /** + * Create a strongly typed function declaration with an associated function reference. This + * version allows an arbitrary JsonObject as output rather than a strict schema. + * + * @param functionName the name of the function (to the model) + * @param description the description of the function + * @param inputSchema the object the model must provide to you as input + * @param functionReference the function that will be executed when requested by the model. + */ public fun create( functionName: String, description: String, From 67d3c266abdeddb9a9375e6be87b41b54781a034 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Fri, 12 Dec 2025 16:11:04 -0800 Subject: [PATCH 21/30] fix tests --- .../kotlin/com/google/firebase/ai/Chat.kt | 23 +++++++++++-------- .../com/google/firebase/ai/GenerativeModel.kt | 6 +++++ .../ai/type/FunctionDeclarationTest.kt | 8 +++++-- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt index f06eb448d92..9232f4f944c 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt @@ -75,7 +75,7 @@ public class Chat( history.add(prompt) history.add(response.candidates.first().content) - if (responsePart is FunctionCallPart) { + if (responsePart is FunctionCallPart && model.hasFunction(responsePart)) { val output = model.executeFunction(responsePart) prompt = Content("function", listOf(FunctionResponsePart(responsePart.name, output))) } else { @@ -213,14 +213,19 @@ public class Chat( val functionCall = response.candidates.first().content.parts.first { it is FunctionCallPart } as FunctionCallPart - val output = model.executeFunction(functionCall) - val functionResponse = - Content("function", listOf(FunctionResponsePart(functionCall.name, output))) - tempHistory.add(response.candidates.first().content) - tempHistory.add(functionResponse) - model - .generateContentStream(listOf(*history.toTypedArray(), *tempHistory.toTypedArray())) - .collect { automaticFunctionExecutingTransform(transformer, tempHistory, it) } + if (model.hasFunction(functionCall)) { + val output = model.executeFunction(functionCall) + val functionResponse = + Content("function", listOf(FunctionResponsePart(functionCall.name, output))) + tempHistory.add(response.candidates.first().content) + tempHistory.add(functionResponse) + model + .generateContentStream(listOf(*history.toTypedArray(), *tempHistory.toTypedArray())) + .collect { automaticFunctionExecutingTransform(transformer, tempHistory, it) } + } else { + transformer.emit(response) + tempHistory.add(Content("model", listOf(part))) + } } else -> { transformer.emit(response) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt index 55f81b79d19..8e2ef4bec4d 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt @@ -273,6 +273,12 @@ internal constructor( return countTokens(content { image(prompt) }) } + internal fun hasFunction(call: FunctionCallPart): Boolean { + return tools + ?.flatMap { it.autoFunctionDeclarations?.filterNotNull() ?: emptyList() } + ?.firstOrNull { it.name == call.name } != null + } + @OptIn(InternalSerializationApi::class) internal suspend fun executeFunction(call: FunctionCallPart): JsonObject { if (tools == null) { diff --git a/firebase-ai/src/test/java/com/google/firebase/ai/type/FunctionDeclarationTest.kt b/firebase-ai/src/test/java/com/google/firebase/ai/type/FunctionDeclarationTest.kt index 7719044b498..575dff886c7 100644 --- a/firebase-ai/src/test/java/com/google/firebase/ai/type/FunctionDeclarationTest.kt +++ b/firebase-ai/src/test/java/com/google/firebase/ai/type/FunctionDeclarationTest.kt @@ -48,7 +48,9 @@ internal class FunctionDeclarationTest { "required": [ "userID" ] - } + }, + "parametersJsonSchema": null, + "responseJsonSchema": null } """ .trimIndent() @@ -90,7 +92,9 @@ internal class FunctionDeclarationTest { "required": [ "userID" ] - } + }, + "parametersJsonSchema": null, + "responseJsonSchema": null } """ .trimIndent() From fe5255adb40cc8ddcfe59896e1c28f4577dde1f9 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Fri, 12 Dec 2025 16:13:07 -0800 Subject: [PATCH 22/30] bump version and add changelog --- firebase-ai/CHANGELOG.md | 2 ++ firebase-ai/gradle.properties | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/firebase-ai/CHANGELOG.md b/firebase-ai/CHANGELOG.md index 7c4c924ab9f..12c317325f1 100644 --- a/firebase-ai/CHANGELOG.md +++ b/firebase-ai/CHANGELOG.md @@ -1,4 +1,6 @@ # Unreleased +- [feature] Added `JsonSchema`, `AutoFunctionDeclaration`, support for automatic function calling, + and the firebase-ai-ksp processor's annotation (`Generable`, `Tool`, and `Guide`) # 17.7.0 diff --git a/firebase-ai/gradle.properties b/firebase-ai/gradle.properties index 215d4a50f32..2181d491391 100644 --- a/firebase-ai/gradle.properties +++ b/firebase-ai/gradle.properties @@ -12,5 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. -version=17.7.1 +version=17.8.0 latestReleasedVersion=17.7.0 From db689c4560f310bbc3b089b57d0eddcc6564df56 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Fri, 12 Dec 2025 16:24:20 -0800 Subject: [PATCH 23/30] fix major version bumps and formatting --- firebase-ai/CHANGELOG.md | 1 + .../kotlin/com/google/firebase/ai/Chat.kt | 20 +++++++++---------- .../com/google/firebase/ai/type/Tool.kt | 16 ++++++++++++++- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/firebase-ai/CHANGELOG.md b/firebase-ai/CHANGELOG.md index 12c317325f1..7b78b0df0a3 100644 --- a/firebase-ai/CHANGELOG.md +++ b/firebase-ai/CHANGELOG.md @@ -1,4 +1,5 @@ # Unreleased + - [feature] Added `JsonSchema`, `AutoFunctionDeclaration`, support for automatic function calling, and the firebase-ai-ksp processor's annotation (`Generable`, `Tool`, and `Guide`) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt index 9232f4f944c..65e335e9aa6 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/Chat.kt @@ -52,32 +52,32 @@ public class Chat( private var lock = Semaphore(1) /** - * Sends a message using the provided [inputPrompt]; automatically providing the existing - * [history] as context. + * Sends a message using the provided [prompt]; automatically providing the existing [history] as + * context. * * If successful, the message and response will be added to the [history]. If unsuccessful, * [history] will remain unchanged. * - * @param inputPrompt The input that, together with the history, will be given to the model as the + * @param prompt The input that, together with the history, will be given to the model as the * prompt. - * @throws InvalidStateException if [inputPrompt] is not coming from the 'user' role. + * @throws InvalidStateException if [prompt] is not coming from the 'user' role. * @throws InvalidStateException if the [Chat] instance has an active request. */ - public suspend fun sendMessage(inputPrompt: Content): GenerateContentResponse { - inputPrompt.assertComesFromUser() + public suspend fun sendMessage(prompt: Content): GenerateContentResponse { + prompt.assertComesFromUser() attemptLock() var response: GenerateContentResponse - var prompt = inputPrompt + var tempPrompt = prompt try { while (true) { - response = model.generateContent(listOf(*history.toTypedArray(), prompt)) + response = model.generateContent(listOf(*history.toTypedArray(), tempPrompt)) val responsePart = response.candidates.first().content.parts.first() - history.add(prompt) + history.add(tempPrompt) history.add(response.candidates.first().content) if (responsePart is FunctionCallPart && model.hasFunction(responsePart)) { val output = model.executeFunction(responsePart) - prompt = Content("function", listOf(FunctionResponsePart(responsePart.name, output))) + tempPrompt = Content("function", listOf(FunctionResponsePart(responsePart.name, output))) } else { break } diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt index 8b3e4329faf..ad8aeb40b07 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/Tool.kt @@ -69,7 +69,21 @@ internal constructor( @JvmStatic public fun functionDeclarations( functionDeclarations: List? = null, - autoFunctionDeclarations: List>? = null + ): Tool { + @OptIn(PublicPreviewAPI::class) return Tool(functionDeclarations, null, null, null, null) + } + + /** + * Creates a [Tool] instance that provides the model with access to the [functionDeclarations]. + * + * @param functionDeclarations The list of functions that this tool allows the model access to. + * @param autoFunctionDeclarations The list of functions that this tool has access to which + * should be executed automatically + */ + @JvmStatic + public fun functionDeclarations( + functionDeclarations: List? = null, + autoFunctionDeclarations: List>? ): Tool { @OptIn(PublicPreviewAPI::class) return Tool(functionDeclarations, autoFunctionDeclarations, null, null, null) From fe0b47b67e9d70d1bd79025e7b9b28c44ae560f1 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Fri, 12 Dec 2025 16:27:36 -0800 Subject: [PATCH 24/30] fix ksp-processor readme --- firebase-ai-ksp-processor/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase-ai-ksp-processor/README.md b/firebase-ai-ksp-processor/README.md index 6460d9ff410..50ecd9372d5 100644 --- a/firebase-ai-ksp-processor/README.md +++ b/firebase-ai-ksp-processor/README.md @@ -11,3 +11,4 @@ dependencies { ksp("com.google.firebase:firebase-ai-processor:1.0.0") } ``` + From eaf76713df155c3a01e153b227efcd6eb330b6a8 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Fri, 12 Dec 2025 16:52:14 -0800 Subject: [PATCH 25/30] update api.txt --- firebase-ai/api.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/firebase-ai/api.txt b/firebase-ai/api.txt index 23c5117122e..a1daa7e8d38 100644 --- a/firebase-ai/api.txt +++ b/firebase-ai/api.txt @@ -5,7 +5,7 @@ package com.google.firebase.ai { ctor public Chat(com.google.firebase.ai.GenerativeModel model, java.util.List history = java.util.ArrayList()); method public java.util.List getHistory(); method public suspend Object? sendMessage(android.graphics.Bitmap prompt, kotlin.coroutines.Continuation); - method public suspend Object? sendMessage(com.google.firebase.ai.type.Content inputPrompt, kotlin.coroutines.Continuation); + method public suspend Object? sendMessage(com.google.firebase.ai.type.Content prompt, kotlin.coroutines.Continuation); method public suspend Object? sendMessage(String prompt, kotlin.coroutines.Continuation); method public kotlinx.coroutines.flow.Flow sendMessageStream(android.graphics.Bitmap prompt); method public kotlinx.coroutines.flow.Flow sendMessageStream(com.google.firebase.ai.type.Content prompt); @@ -1495,7 +1495,8 @@ package com.google.firebase.ai.type { public final class Tool { method public static com.google.firebase.ai.type.Tool codeExecution(); - method public static com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null, java.util.List>? autoFunctionDeclarations = null); + method public static com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null); + method public static com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null, java.util.List>? autoFunctionDeclarations); method public static com.google.firebase.ai.type.Tool googleSearch(com.google.firebase.ai.type.GoogleSearch googleSearch = com.google.firebase.ai.type.GoogleSearch()); method @com.google.firebase.ai.type.PublicPreviewAPI public static com.google.firebase.ai.type.Tool urlContext(com.google.firebase.ai.type.UrlContext urlContext = com.google.firebase.ai.type.UrlContext()); field public static final com.google.firebase.ai.type.Tool.Companion Companion; @@ -1503,7 +1504,8 @@ package com.google.firebase.ai.type { public static final class Tool.Companion { method public com.google.firebase.ai.type.Tool codeExecution(); - method public com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null, java.util.List>? autoFunctionDeclarations = null); + method public com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null); + method public com.google.firebase.ai.type.Tool functionDeclarations(java.util.List? functionDeclarations = null, java.util.List>? autoFunctionDeclarations); method public com.google.firebase.ai.type.Tool googleSearch(com.google.firebase.ai.type.GoogleSearch googleSearch = com.google.firebase.ai.type.GoogleSearch()); method @com.google.firebase.ai.type.PublicPreviewAPI public com.google.firebase.ai.type.Tool urlContext(com.google.firebase.ai.type.UrlContext urlContext = com.google.firebase.ai.type.UrlContext()); } From 2e2a6b18faa26571af8e751e75375aeb1915be3d Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Tue, 16 Dec 2025 13:27:09 -0800 Subject: [PATCH 26/30] add description to generable, and restrict guide to fields only --- .../firebase/ai/ksp/SchemaSymbolProcessor.kt | 15 ++++++++++----- .../google/firebase/ai/annotations/Generable.kt | 6 +++++- .../com/google/firebase/ai/annotations/Guide.kt | 2 +- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 71211448ebc..208cf797f46 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -127,10 +127,15 @@ public class SchemaSymbolProcessor( val kdocString = type.declaration.docString ?: "" val baseKdoc = extractBaseKdoc(kdocString) val propertyDocs = extractPropertyKdocs(kdocString) - val guideClassAnnotation = - type.annotations.firstOrNull() { it.shortName.getShortName() == "Guide" } + val generableClassAnnotation = + type.annotations.firstOrNull() { it.shortName.getShortName() == "Generable" } val description = - getDescriptionFromAnnotations(guideAnnotation, guideClassAnnotation, description, baseKdoc) + getDescriptionFromAnnotations( + guideAnnotation, + generableClassAnnotation, + description, + baseKdoc + ) val minimum = getDoubleFromAnnotation(guideAnnotation, "minimum") val maximum = getDoubleFromAnnotation(guideAnnotation, "maximum") val minItems = getIntFromAnnotation(guideAnnotation, "minItems") @@ -267,13 +272,13 @@ public class SchemaSymbolProcessor( private fun getDescriptionFromAnnotations( guideAnnotation: KSAnnotation?, - guideClassAnnotation: KSAnnotation?, + generableClassAnnotation: KSAnnotation?, description: String?, baseKdoc: String?, ): String? { val guidePropertyDescription = getStringFromAnnotation(guideAnnotation, "description") - val guideClassDescription = getStringFromAnnotation(guideClassAnnotation, "description") + val guideClassDescription = getStringFromAnnotation(generableClassAnnotation, "description") return guidePropertyDescription ?: guideClassDescription ?: description ?: baseKdoc } diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt index fe217272bbd..99ca663c3a7 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt @@ -20,7 +20,11 @@ package com.google.firebase.ai.annotations * This annotation is used with the firebase-ai-ksp-processor plugin to generate JsonSchema that * match an existing kotlin class structure. For more info see: * https://github.com/firebase/firebase-android-sdk/blob/main/firebase-ai-ksp-processor/README.md + * + * @property description a description of the class */ @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) -public annotation class Generable +public annotation class Generable( + public val description: String = "" +) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt index 605f163d955..ddc8e892757 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Guide.kt @@ -27,7 +27,7 @@ package com.google.firebase.ai.annotations * @property format the format that a field must conform to * @property pattern the regular expression that a string field must conform to */ -@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY) +@Target(AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.SOURCE) public annotation class Guide( public val description: String = "", From 08a69187cd3f88ead892ff57b9c3198c00675571 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Tue, 16 Dec 2025 13:36:32 -0800 Subject: [PATCH 27/30] minor ksp processor formatting --- .../kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt index 208cf797f46..0384846d8f5 100644 --- a/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt +++ b/firebase-ai-ksp-processor/src/main/kotlin/com/google/firebase/ai/ksp/SchemaSymbolProcessor.kt @@ -245,7 +245,8 @@ public class SchemaSymbolProcessor( } if ((format != null || pattern != null) && className.canonicalName != "kotlin.String") { logger.warn( - "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, format and pattern are not a valid parameter to specify in @Guide" + "${parentType?.toClassName()?.simpleName?.let { "$it." }}$name is not a String type, " + + "format and pattern are not a valid parameter to specify in @Guide" ) } if (minimum != null) { From ad1014381d5b9789113ca04e291f45ad5c7cd2c5 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Tue, 16 Dec 2025 14:41:20 -0800 Subject: [PATCH 28/30] add response json schema param to generationConfig --- .../firebase/ai/annotations/Generable.kt | 4 +- .../firebase/ai/type/GenerationConfig.kt | 70 ++++++++++++++++++- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt index 99ca663c3a7..64b18ca071b 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/annotations/Generable.kt @@ -25,6 +25,4 @@ package com.google.firebase.ai.annotations */ @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) -public annotation class Generable( - public val description: String = "" -) +public annotation class Generable(public val description: String = "") diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerationConfig.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerationConfig.kt index a496098787f..23a29c6d9f7 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerationConfig.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerationConfig.kt @@ -67,7 +67,10 @@ import kotlinx.serialization.Serializable * - `application/json`: JSON response in the candidates. * * @property responseSchema Output schema of the generated candidate text. If set, a compatible - * [responseMimeType] must also be set. + * [responseMimeType] must also be set. This is mutually exclusive with [responseJsonSchema]. + * + * @property responseJsonSchema Output schema of the generated candidate text. If set, a compatible + * [responseMimeType] must also be set. This is mutually exclusive with [responseSchema]. * * Compatible MIME types: * - `application/json`: Schema for JSON response. @@ -90,6 +93,7 @@ private constructor( internal val stopSequences: List?, internal val responseMimeType: String?, internal val responseSchema: Schema?, + internal val responseJsonSchema: JsonSchema<*>?, internal val responseModalities: List?, internal val thinkingConfig: ThinkingConfig?, ) { @@ -120,6 +124,8 @@ private constructor( * * @property responseSchema See [GenerationConfig.responseSchema]. * + * @property responseJsonSchema See [GenerationConfig.responseJsonSchema] + * * @property responseModalities See [GenerationConfig.responseModalities]. * * @see [generationConfig] @@ -135,9 +141,40 @@ private constructor( @JvmField public var stopSequences: List? = null @JvmField public var responseMimeType: String? = null @JvmField public var responseSchema: Schema? = null + @JvmField public var responseJsonSchema: JsonSchema<*>? = null @JvmField public var responseModalities: List? = null @JvmField public var thinkingConfig: ThinkingConfig? = null + internal constructor( + temperature: Float? = null, + topK: Int? = null, + topP: Float? = null, + candidateCount: Int? = null, + maxOutputTokens: Int? = null, + presencePenalty: Float? = null, + frequencyPenalty: Float? = null, + stopSequences: List? = null, + responseMimeType: String? = null, + responseSchema: Schema? = null, + responseJsonSchema: JsonSchema<*>? = null, + responseModalities: List? = null, + thinkingConfig: ThinkingConfig? = null, + ) { + this.temperature = temperature + this.topK = topK + this.topP = topP + this.candidateCount = candidateCount + this.maxOutputTokens = maxOutputTokens + this.stopSequences = stopSequences + this.presencePenalty = presencePenalty + this.frequencyPenalty = frequencyPenalty + this.responseMimeType = responseMimeType + this.responseSchema = responseSchema + this.responseJsonSchema = responseJsonSchema + this.responseModalities = responseModalities + this.thinkingConfig = thinkingConfig + } + public fun setTemperature(temperature: Float?): Builder = apply { this.temperature = temperature } @@ -164,6 +201,9 @@ private constructor( public fun setResponseSchema(responseSchema: Schema?): Builder = apply { this.responseSchema = responseSchema } + public fun setResponseSchemaJson(responseSchemaJson: JsonSchema<*>?): Builder = apply { + this.responseJsonSchema = responseSchemaJson + } public fun setResponseModalities(responseModalities: List?): Builder = apply { this.responseModalities = responseModalities } @@ -172,8 +212,11 @@ private constructor( } /** Create a new [GenerationConfig] with the attached arguments. */ - public fun build(): GenerationConfig = - GenerationConfig( + public fun build(): GenerationConfig { + if (responseSchema != null && responseJsonSchema != null) { + throw InvalidStateException("responseSchema and responseJsonSchema are mutually exclusive.") + } + return GenerationConfig( temperature = temperature, topK = topK, topP = topP, @@ -184,11 +227,30 @@ private constructor( frequencyPenalty = frequencyPenalty, responseMimeType = responseMimeType, responseSchema = responseSchema, + responseJsonSchema = responseJsonSchema, responseModalities = responseModalities, thinkingConfig = thinkingConfig ) + } } + public fun toBuilder(): Builder = + Builder( + temperature = temperature, + topK = topK, + topP = topP, + candidateCount = candidateCount, + maxOutputTokens = maxOutputTokens, + stopSequences = stopSequences, + presencePenalty = presencePenalty, + frequencyPenalty = frequencyPenalty, + responseMimeType = responseMimeType, + responseSchema = responseSchema, + responseJsonSchema = responseJsonSchema, + responseModalities = responseModalities, + thinkingConfig = thinkingConfig + ) + internal fun toInternal() = Internal( temperature = temperature, @@ -201,6 +263,7 @@ private constructor( presencePenalty = presencePenalty, responseMimeType = responseMimeType, responseSchema = responseSchema?.toInternalOpenApi(), + responseJsonSchema = responseJsonSchema?.toInternalJson(), responseModalities = responseModalities?.map { it.toInternal() }, thinkingConfig = thinkingConfig?.toInternal() ) @@ -217,6 +280,7 @@ private constructor( @SerialName("presence_penalty") val presencePenalty: Float? = null, @SerialName("frequency_penalty") val frequencyPenalty: Float? = null, @SerialName("response_schema") val responseSchema: Schema.InternalOpenAPI? = null, + @SerialName("response_json_schema") val responseJsonSchema: Schema.InternalJson? = null, @SerialName("response_modalities") val responseModalities: List? = null, @SerialName("thinking_config") val thinkingConfig: ThinkingConfig.Internal? = null ) From 6892a1e234680dda67a880ecf9163a0e5cb49934 Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Tue, 16 Dec 2025 14:45:00 -0800 Subject: [PATCH 29/30] update api.txt --- firebase-ai/api.txt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/firebase-ai/api.txt b/firebase-ai/api.txt index a1daa7e8d38..3b73e0372c0 100644 --- a/firebase-ai/api.txt +++ b/firebase-ai/api.txt @@ -101,9 +101,11 @@ package com.google.firebase.ai { package com.google.firebase.ai.annotations { @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets=kotlin.annotation.AnnotationTarget.CLASS) public @interface Generable { + method public abstract String description() default ""; + property public abstract String description; } - @kotlin.annotation.Retention(kotlin.annotation.AnnotationRetention.SOURCE) @kotlin.annotation.Target(allowedTargets={kotlin.annotation.AnnotationTarget.CLASS, kotlin.annotation.AnnotationTarget.PROPERTY}) public @interface Guide { + @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 format() default ""; method public abstract int maxItems() default -1; @@ -498,11 +500,11 @@ package com.google.firebase.ai.type { } public final class GenerationConfig { + method public com.google.firebase.ai.type.GenerationConfig.Builder toBuilder(); field public static final com.google.firebase.ai.type.GenerationConfig.Companion Companion; } public static final class GenerationConfig.Builder { - ctor public GenerationConfig.Builder(); method public com.google.firebase.ai.type.GenerationConfig build(); method public com.google.firebase.ai.type.GenerationConfig.Builder setCandidateCount(Integer? candidateCount); method public com.google.firebase.ai.type.GenerationConfig.Builder setFrequencyPenalty(Float? frequencyPenalty); @@ -511,6 +513,7 @@ package com.google.firebase.ai.type { method public com.google.firebase.ai.type.GenerationConfig.Builder setResponseMimeType(String? responseMimeType); method public com.google.firebase.ai.type.GenerationConfig.Builder setResponseModalities(java.util.List? responseModalities); method public com.google.firebase.ai.type.GenerationConfig.Builder setResponseSchema(com.google.firebase.ai.type.Schema? responseSchema); + method public com.google.firebase.ai.type.GenerationConfig.Builder setResponseSchemaJson(com.google.firebase.ai.type.JsonSchema? responseSchemaJson); method public com.google.firebase.ai.type.GenerationConfig.Builder setStopSequences(java.util.List? stopSequences); method public com.google.firebase.ai.type.GenerationConfig.Builder setTemperature(Float? temperature); method public com.google.firebase.ai.type.GenerationConfig.Builder setThinkingConfig(com.google.firebase.ai.type.ThinkingConfig? thinkingConfig); @@ -520,6 +523,7 @@ package com.google.firebase.ai.type { field public Float? frequencyPenalty; field public Integer? maxOutputTokens; field public Float? presencePenalty; + field public com.google.firebase.ai.type.JsonSchema? responseJsonSchema; field public String? responseMimeType; field public java.util.List? responseModalities; field public com.google.firebase.ai.type.Schema? responseSchema; From e94e207e371182883643c638615826da4f3fc4ab Mon Sep 17 00:00:00 2001 From: David Motsonashvili Date: Tue, 16 Dec 2025 17:10:12 -0800 Subject: [PATCH 30/30] add GenerateObjectResponse and generateObject api --- firebase-ai/api.txt | 8 +++ .../com/google/firebase/ai/GenerativeModel.kt | 65 +++++++++++++++++-- .../firebase/ai/common/APIController.kt | 1 + .../ai/type/GenerateObjectResponse.kt | 43 ++++++++++++ 4 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt diff --git a/firebase-ai/api.txt b/firebase-ai/api.txt index 3b73e0372c0..e8b4d45dbf8 100644 --- a/firebase-ai/api.txt +++ b/firebase-ai/api.txt @@ -73,6 +73,8 @@ package com.google.firebase.ai { method public kotlinx.coroutines.flow.Flow generateContentStream(com.google.firebase.ai.type.Content prompt, com.google.firebase.ai.type.Content... prompts); method public kotlinx.coroutines.flow.Flow generateContentStream(String prompt); method public kotlinx.coroutines.flow.Flow generateContentStream(java.util.List prompt); + method public suspend Object? generateObject(com.google.firebase.ai.type.JsonSchema jsonSchema, com.google.firebase.ai.type.Content prompt, com.google.firebase.ai.type.Content[] prompts, kotlin.coroutines.Continuation>); + method public suspend Object? generateObject(com.google.firebase.ai.type.JsonSchema jsonSchema, String prompt, kotlin.coroutines.Continuation>); method public com.google.firebase.ai.Chat startChat(java.util.List history = emptyList()); } @@ -499,6 +501,12 @@ package com.google.firebase.ai.type { property public final com.google.firebase.ai.type.UsageMetadata? usageMetadata; } + public final class GenerateObjectResponse { + method public T? getObject(int candidateIndex = 0); + method public com.google.firebase.ai.type.GenerateContentResponse getResponse(); + property public final com.google.firebase.ai.type.GenerateContentResponse response; + } + public final class GenerationConfig { method public com.google.firebase.ai.type.GenerationConfig.Builder toBuilder(); field public static final com.google.firebase.ai.type.GenerationConfig.Companion Companion; diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt index 8e2ef4bec4d..1eef17e7aa5 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/GenerativeModel.kt @@ -29,10 +29,12 @@ import com.google.firebase.ai.type.FinishReason import com.google.firebase.ai.type.FirebaseAIException import com.google.firebase.ai.type.FunctionCallPart import com.google.firebase.ai.type.GenerateContentResponse +import com.google.firebase.ai.type.GenerateObjectResponse import com.google.firebase.ai.type.GenerationConfig import com.google.firebase.ai.type.GenerativeBackend import com.google.firebase.ai.type.GenerativeBackendEnum import com.google.firebase.ai.type.InvalidStateException +import com.google.firebase.ai.type.JsonSchema import com.google.firebase.ai.type.PromptBlockedException import com.google.firebase.ai.type.RequestOptions import com.google.firebase.ai.type.ResponseStoppedException @@ -118,11 +120,43 @@ internal constructor( vararg prompts: Content ): GenerateContentResponse = try { - controller.generateContent(constructRequest(prompt, *prompts)).toPublic().validate() + controller.generateContent(constructRequest(null, prompt, *prompts)).toPublic().validate() } catch (e: Throwable) { throw FirebaseAIException.from(e) } + /** + * Generates an object from the input [Content] given to the model as a prompt. + * + * @param jsonSchema A schema for the output + * @param prompt The input(s) given to the model as a prompt. + * @return The content generated by the model. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun generateObject( + jsonSchema: JsonSchema, + prompt: Content, + vararg prompts: Content + ): GenerateObjectResponse { + try { + val config = + (generationConfig?.toBuilder() ?: GenerationConfig.builder()) + .setResponseSchemaJson(jsonSchema) + .setResponseMimeType("application/json") + .build() + return GenerateObjectResponse( + controller + .generateContent(constructRequest(config, prompt, *prompts)) + .toPublic() + .validate(), + jsonSchema.clazz + ) + } catch (e: Throwable) { + throw FirebaseAIException.from(e) + } + } + /** * Generates new content from the input [Content] given to the model as a prompt. * @@ -151,7 +185,7 @@ internal constructor( vararg prompts: Content ): Flow = controller - .generateContentStream(constructRequest(prompt, *prompts)) + .generateContentStream(constructRequest(null, prompt, *prompts)) .catch { throw FirebaseAIException.from(it) } .map { it.toPublic().validate() } @@ -180,6 +214,20 @@ internal constructor( public suspend fun generateContent(prompt: String): GenerateContentResponse = generateContent(content { text(prompt) }) + /** + * Generates an object from the text input given to the model as a prompt. + * + * @param jsonSchema A schema for the output + * @param prompt The text to be send to the model as a prompt. + * @return The content generated by the model. + * @throws [FirebaseAIException] if the request failed. + * @see [FirebaseAIException] for types of errors. + */ + public suspend fun generateObject( + jsonSchema: JsonSchema, + prompt: String + ): GenerateObjectResponse = generateObject(jsonSchema, content { text(prompt) }) + /** * Generates new content as a stream from the text input given to the model as a prompt. * @@ -317,7 +365,7 @@ internal constructor( } @OptIn(ExperimentalSerializationApi::class) - private fun constructRequest(vararg prompt: Content) = + private fun constructRequest(overrideConfig: GenerationConfig? = null, vararg prompt: Content) = GenerateContentRequest( modelName, prompt.map { it.toInternal() }, @@ -333,18 +381,21 @@ internal constructor( } } ?.map { it.toInternal() }, - generationConfig?.toInternal(), + (overrideConfig ?: generationConfig)?.toInternal(), tools?.map { it.toInternal() }, toolConfig?.toInternal(), systemInstruction?.copy(role = "system")?.toInternal(), ) - private fun constructRequest(prompt: List) = constructRequest(*prompt.toTypedArray()) + private fun constructRequest(prompt: List) = + constructRequest(null, *prompt.toTypedArray()) private fun constructCountTokensRequest(vararg prompt: Content) = when (generativeBackend.backend) { - GenerativeBackendEnum.GOOGLE_AI -> CountTokensRequest.forGoogleAI(constructRequest(*prompt)) - GenerativeBackendEnum.VERTEX_AI -> CountTokensRequest.forVertexAI(constructRequest(*prompt)) + GenerativeBackendEnum.GOOGLE_AI -> + CountTokensRequest.forGoogleAI(constructRequest(null, *prompt)) + GenerativeBackendEnum.VERTEX_AI -> + CountTokensRequest.forVertexAI(constructRequest(null, *prompt)) } private fun GenerateContentResponse.validate() = apply { diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt index e992f92e674..9d64cfb54df 100644 --- a/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/common/APIController.kt @@ -163,6 +163,7 @@ internal constructor( } catch (e: Throwable) { throw FirebaseAIException.from(e) } + suspend fun generateObject() suspend fun templateGenerateContent( templateId: String, diff --git a/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt new file mode 100644 index 00000000000..8fd54a7616e --- /dev/null +++ b/firebase-ai/src/main/kotlin/com/google/firebase/ai/type/GenerateObjectResponse.kt @@ -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. + */ + +package com.google.firebase.ai.type + +import kotlin.reflect.KClass +import kotlinx.serialization.InternalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.serializerOrNull + +public class GenerateObjectResponse +internal constructor(public val response: GenerateContentResponse, internal val clazz: KClass) { + + @OptIn(InternalSerializationApi::class) + public fun getObject(candidateIndex: Int = 0): T? { + val candidate = response.candidates[candidateIndex] + val deserializer = + clazz.serializerOrNull() + ?: throw RuntimeException("Object type ${clazz.qualifiedName} is not @Serializable") + val text = + candidate.content.parts + .filter { !it.isThought } + .filterIsInstance() + .joinToString(" ") { it.text } + if (text.isEmpty()) { + return null + } + return Json.decodeFromString(deserializer, text) + } +}