Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a0ffcd5
initial implementation
milaGGL Jul 6, 2026
64ef2ef
remove objectResponse from Candidate
milaGGL Jul 6, 2026
35e128a
update naming, the on-device GenerateObjectResponse, and ObjectSource
milaGGL Jul 8, 2026
60b3c12
update GenerateObjectResponse
milaGGL Jul 9, 2026
8d670e8
update the name of shadowClass property
milaGGL Jul 10, 2026
565be05
update Generable
milaGGL Jul 27, 2026
b12b96d
remove local dependency
milaGGL Jul 27, 2026
e4fc45a
remove debug log
milaGGL Jul 27, 2026
f98bd5c
Merge branch 'main' into mila-support-structured-ouput-locally
milaGGL Jul 27, 2026
e3dbf21
update api txt file
milaGGL Jul 28, 2026
7e1f92b
Fix ConvertersTest by removing brittle ML Kit default assertions
milaGGL Jul 28, 2026
cda79ef
Update SchemaSymbolProcessorVisitor.kt
milaGGL Jul 28, 2026
1944371
Address code review feedback, add dependency on genai-schema
milaGGL Jul 28, 2026
2981a63
Merge branch 'main' into mila-support-structured-ouput-locally
milaGGL Jul 28, 2026
1c8e412
Merge branch 'main' into mila-support-structured-ouput-locally
milaGGL Aug 4, 2026
ecd0d71
resolve some comments
milaGGL Aug 4, 2026
bc0b6ff
test both nested and composite Generable classes
milaGGL Aug 4, 2026
a665954
Merge branch 'main' into mila-support-structured-ouput-locally
milaGGL Aug 4, 2026
6ea68a1
Merge branch 'main' into mila-support-structured-ouput-locally
milaGGL Aug 4, 2026
ca714f7
format changelog
milaGGL Aug 4, 2026
6c7e01b
Merge branch 'main' into mila-support-structured-ouput-locally
milaGGL Aug 7, 2026
69a8c59
resolve comments
milaGGL Aug 7, 2026
396f724
Merge branch 'mila-support-structured-ouput-locally' of https://githu…
milaGGL Aug 7, 2026
6794859
Update OnDeviceGenerativeModelProvider.kt
milaGGL Aug 7, 2026
17953d2
Update SchemaSymbolProcessorVisitor.kt
milaGGL Aug 7, 2026
ef07aea
Merge branch 'main' into mila-support-structured-ouput-locally
milaGGL Aug 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,18 @@ import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSType
import com.google.devtools.ksp.symbol.KSVisitorVoid
import com.google.devtools.ksp.symbol.Modifier
import com.squareup.kotlinpoet.AnnotationSpec
import com.squareup.kotlinpoet.ClassName
import com.squareup.kotlinpoet.CodeBlock
import com.squareup.kotlinpoet.FileSpec
import com.squareup.kotlinpoet.FunSpec
import com.squareup.kotlinpoet.KModifier
import com.squareup.kotlinpoet.ParameterSpec
import com.squareup.kotlinpoet.ParameterizedTypeName
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
import com.squareup.kotlinpoet.PropertySpec
import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.TypeSpec
import com.squareup.kotlinpoet.ksp.toClassName
import com.squareup.kotlinpoet.ksp.toTypeName
import com.squareup.kotlinpoet.ksp.writeTo
Expand All @@ -57,30 +63,26 @@ internal class SchemaSymbolProcessorVisitor(
codeGenerator,
Dependencies(true, containingFile),
)
val companionFile = generateMlKitCompanionFileSpec(classDeclaration)
companionFile.writeTo(
codeGenerator,
Dependencies(true, containingFile),
)
}

fun generateFileSpec(classDeclaration: KSClassDeclaration): FileSpec {
val className = classDeclaration.toClassName()
val simpleNamesJoined = className.simpleNames.joinToString("_")
Comment thread
milaGGL marked this conversation as resolved.
return FileSpec.builder(
classDeclaration.packageName.asString(),
"${classDeclaration.simpleName.asString()}GeneratedSchema",
className.packageName,
"${simpleNamesJoined}GeneratedSchema",
)
.addImport("com.google.firebase.ai.type", "JsonSchema")
.addFunction(
FunSpec.builder("firebaseAISchema")
.receiver(
ClassName(
classDeclaration.packageName.asString(),
classDeclaration.simpleName.asString() + ".Companion"
)
)
.receiver(className.nestedClass("Companion"))
.returns(
ClassName("com.google.firebase.ai.type", "JsonSchema")
.parameterizedBy(
ClassName(
classDeclaration.packageName.asString(),
classDeclaration.simpleName.asString()
)
)
ClassName("com.google.firebase.ai.type", "JsonSchema").parameterizedBy(className)
)
.addAnnotation(Generated::class)
.addCode(
Expand Down Expand Up @@ -142,7 +144,17 @@ internal class SchemaSymbolProcessorVisitor(
builder.addStatement("JsonSchema.double(").indent()
}
"kotlin.String" -> {
builder.addStatement("JsonSchema.string(").indent()
if (!guideValues.enumValues.isNullOrEmpty()) {
val enumElements = guideValues.enumValues.joinToString { "%S" }
builder
.addStatement("JsonSchema.enumeration(")
.indent()
.add("values = listOf(")
.addStatement(enumElements, *guideValues.enumValues.toTypedArray())
.addStatement("),")
} else {
builder.addStatement("JsonSchema.string(").indent()
}
}
"kotlin.collections.List" -> {

Expand All @@ -155,7 +167,12 @@ internal class SchemaSymbolProcessorVisitor(
throw RuntimeException()
}
val listParamCodeBlock =
generateCodeBlockForSchema(type = listTypeParam.resolve(), parentType = type)
generateCodeBlockForSchema(
type = listTypeParam.resolve(),
parentType = type,
guideAnnotation =
if (!guideValues.enumValues.isNullOrEmpty()) guideAnnotation else null,
)
builder
.addStatement("JsonSchema.array(")
.indent()
Expand All @@ -171,14 +188,13 @@ internal class SchemaSymbolProcessorVisitor(
.filterIsInstance(KSClassDeclaration::class.java)
.map { it.simpleName.asString() }
.toList()
val enumElements = enumValues.joinToString { "%S" }
builder
.addStatement("JsonSchema.enumeration(")
.indent()
.addStatement("clazz = ${qualifiedName.asString()}::class,")
.addStatement("values = listOf(")
.indent()
.addStatement(enumValues.joinToString { "\"$it\"" })
.unindent()
.add("values = listOf(")
.addStatement(enumElements, *enumValues.toTypedArray())
.addStatement("),")
} else {
builder
Expand Down Expand Up @@ -248,4 +264,125 @@ internal class SchemaSymbolProcessorVisitor(
builder.addStatement("nullable = %L)", className.isNullable).unindent()
return builder.build()
}

private fun isGenerableClass(type: KSType): Boolean {
return type.declaration.annotations.any { it.shortName.getShortName() == "Generable" }
}

private fun isListOfGenerableClass(type: KSType): Boolean {
val qualifiedName = type.declaration.qualifiedName?.asString()
if (qualifiedName == "kotlin.collections.List" || qualifiedName == "java.util.List") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is it prudent to analyze hierarchy here? I can imagine weird issues if you wanted to use an immutable list or ArrayList or otherwise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We can't safely rely on full hierarchy analysis here because we need to know exactly how to reconstruct the collection when generating the toSdk() map the MLkit response back to developer code. If a developer provided a custom list implementation and we just allowed it through, our KSP processor would generate invalid mapping code that fails to compile on their end.

However, I completely agree that we should support the common variants. I've just updated the KSP processor to explicitly support MutableList and ArrayList properties in @generable classes, along with the correct construction logic to convert them safely in the generated toSdk() method.

val argType = type.arguments.firstOrNull()?.type?.resolve()
if (argType != null) {
return isGenerableClass(argType)
}
}
return false
}

private fun mapToMlKitCompanionType(type: KSType): TypeName {
if (isListOfGenerableClass(type)) {
type.arguments.firstOrNull()?.type?.resolve()?.let { argType ->
val ksClass = argType.declaration as KSClassDeclaration
val argClassName =
ClassName(
ksClass.packageName.asString(),
"${ksClass.toClassName().simpleNames.joinToString("_")}_MlKitCompanion",
)
return ClassName("kotlin.collections", "List").parameterizedBy(argClassName)
}
} else if (isGenerableClass(type)) {
val ksClass = type.declaration as KSClassDeclaration
return ClassName(
ksClass.packageName.asString(),
"${ksClass.toClassName().simpleNames.joinToString("_")}_MlKitCompanion"
)
}
return type.toTypeName()
}

fun generateMlKitCompanionFileSpec(classDeclaration: KSClassDeclaration): FileSpec {
val packageName = classDeclaration.packageName.asString()
val simpleNamesJoined = classDeclaration.toClassName().simpleNames.joinToString("_")
val companionClassName = "${simpleNamesJoined}_MlKitCompanion"
val fileBuilder =
FileSpec.builder(packageName, companionClassName).addAnnotation(Generated::class)

val classBuilder = TypeSpec.classBuilder(companionClassName).addModifiers(KModifier.DATA)
val keepAnnotation = AnnotationSpec.builder(ClassName("androidx.annotation", "Keep")).build()
classBuilder.addAnnotation(keepAnnotation)

val generableAnn =
classDeclaration.annotations.firstOrNull { it.shortName.getShortName() == "Generable" }
val classDesc = getStringFromAnnotation(generableAnn, "description")
Comment thread
milaGGL marked this conversation as resolved.
val mlkitGenerableBuilder =
AnnotationSpec.builder(ClassName("com.google.mlkit.genai.schema.annotations", "Generable"))
if (!classDesc.isNullOrEmpty()) {
mlkitGenerableBuilder.addMember("description = %S", classDesc)
}
classBuilder.addAnnotation(mlkitGenerableBuilder.build())

val primaryConstructor = FunSpec.constructorBuilder()
val toSdkBuilder =
FunSpec.builder("toSdk").addAnnotation(keepAnnotation).returns(classDeclaration.toClassName())
val toSdkArgs = mutableListOf<String>()

classDeclaration.getAllProperties().forEach { property ->
val propName = property.simpleName.asString()
val propType = property.type.resolve()
val typeName = mapToMlKitCompanionType(propType)

val paramBuilder = ParameterSpec.builder(propName, typeName)
val propBuilder = PropertySpec.builder(propName, typeName).initializer(propName)

val guideAnn = property.annotations.firstOrNull { it.shortName.getShortName() == "Guide" }
if (guideAnn != null) {
val guideValues =
getGuideValuesFromAnnotation(guideAnn, getStringFromAnnotation(guideAnn, "description"))
Comment thread
milaGGL marked this conversation as resolved.
val mlkitGuideBuilder =
AnnotationSpec.builder(ClassName("com.google.mlkit.genai.schema.annotations", "Guide"))
if (!guideValues.description.isNullOrEmpty())
mlkitGuideBuilder.addMember("description = %S", guideValues.description)
if (guideValues.minimum != null)
mlkitGuideBuilder.addMember("minimum = %L", guideValues.minimum)
if (guideValues.maximum != null)
mlkitGuideBuilder.addMember("maximum = %L", guideValues.maximum)
if (guideValues.minItems != null)
mlkitGuideBuilder.addMember("minItems = %L", guideValues.minItems)
if (guideValues.maxItems != null)
mlkitGuideBuilder.addMember("maxItems = %L", guideValues.maxItems)
if (!guideValues.format.isNullOrEmpty())
mlkitGuideBuilder.addMember("format = %S", guideValues.format)
if (!guideValues.enumValues.isNullOrEmpty()) {
val enumElements = guideValues.enumValues.joinToString { "%S" }
mlkitGuideBuilder.addMember(
"enumValues = arrayOf($enumElements)",
*guideValues.enumValues.toTypedArray()
)
}
paramBuilder.addAnnotation(mlkitGuideBuilder.build())
}

primaryConstructor.addParameter(paramBuilder.build())
classBuilder.addProperty(propBuilder.build())

if (isListOfGenerableClass(propType)) {
toSdkArgs.add("$propName = this.$propName.map { it.toSdk() }")
} else if (isGenerableClass(propType)) {
toSdkArgs.add("$propName = this.$propName.toSdk()")
} else {
toSdkArgs.add("$propName = this.$propName")
}
}

classBuilder.primaryConstructor(primaryConstructor.build())
toSdkBuilder.addStatement(
"return %T(\n ${toSdkArgs.joinToString(",\n ")}\n)",
classDeclaration.toClassName()
)
classBuilder.addFunction(toSdkBuilder.build())

fileBuilder.addType(classBuilder.build())
return fileBuilder.build()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ internal data class GuideValues(
val minItems: Int?,
val maxItems: Int?,
val format: String?,
val description: String?
val description: String?,
val enumValues: List<String>? = null,
)

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

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

internal fun getStringListFromAnnotation(
guideAnnotation: KSAnnotation?,
listName: String,
): List<String>? {
val rawValue =
guideAnnotation
?.arguments
?.firstOrNull { it.name?.getShortName()?.equals(listName) == true }
?.value
val list =
when (rawValue) {
is List<*> -> rawValue.mapNotNull { it as? String }
is Array<*> -> rawValue.mapNotNull { it as? String }
else -> null
}
if (list.isNullOrEmpty()) {
return null
}
return list
}

internal fun extractBaseKdoc(kdoc: String): String? {
return baseKdocRegex.matchEntire(kdoc)?.groups?.get(1)?.value?.trim().let {
if (it.isNullOrEmpty()) null else it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,27 @@ data class RootSchemaTestClass(
val listTest: List<Int>,
@Guide(description = "most likely true, very rarely false") val booleanTest: Boolean,
val stringTest: String,
val objTest: SecondarySchemaTestClass,
val compositeSchemaTest: SecondarySchemaTestClass,
val enumTest: EnumTest,
@Guide(enumValues = ["NORTH", "SOUTH", "EAST", "WEST"]) val stringEnumTest: String,
val nestedSchemaTest: NestedSchemaTestClass,
) {
@Generable
data class NestedSchemaTestClass(val deeplyNestedString: String) {
companion object
}

companion object
}

/** class kdoc should be used if property kdocs aren't present */
data class SecondarySchemaTestClass(val testInt: Int)
@Generable
data class SecondarySchemaTestClass(
val testInt: Int,
@Guide(description = "A nested string") val testString: String = ""
) {

companion object
}

enum class EnumTest {
A,
Expand Down
Loading
Loading