Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions llm-agent/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ kotlin {
linuxX64()
linuxArm64()
macosArm64()
androidNativeArm32()
jvm()

js {
Expand Down
2 changes: 2 additions & 0 deletions llm-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ kotlin {
linuxX64()
linuxArm64()
macosArm64()
androidNativeArm32()

jvm()

Expand Down Expand Up @@ -93,6 +94,7 @@ kotlin {
val linuxX64Main by getting { dependsOn(nativeMain) }
val linuxArm64Main by getting { dependsOn(nativeMain) }
val macosArm64Main by getting { dependsOn(nativeMain) }
val androidNativeArm32Main by getting { dependsOn(nativeMain) }

if (!project.hasProperty("buildFatJar")) {
val androidMain by getting { dependsOn(registryBasedMain) }
Expand Down
1 change: 1 addition & 0 deletions llm-inference/gemma/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ kotlin {
macosArm64()
linuxX64()
linuxArm64()
androidNativeArm32()

jvm()

Expand Down
4 changes: 4 additions & 0 deletions llm-runtime/gemma-iree/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ kotlin {
linuxX64()
linuxArm64()
macosArm64() // Apple Silicon host dev (mirrors :llm-runtime:kgemma); uses the same nativeMain sources
// ToolCall/CompactCodec/FunctionGemmaChatTemplate for :llm-runtime:kgemma's
// androidNativeArm32 eager CLI — IreeRuntime/GemmaDecoder (the on-device
// vmfb decode loop) aren't used by that path, only the commonMain grammar.
androidNativeArm32()

sourceSets {
commonMain.dependencies {
Expand Down
22 changes: 22 additions & 0 deletions llm-runtime/kgemma/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,18 @@ kotlin {
iosArm64()
iosSimulatorArm64()

// Real EAGER FunctionGemma CLI, unlike the shared nativeMain stub above —
// entry point in its own package (cli.android, not cli) so it doesn't
// collide with nativeMain's `cli.main` in the same compilation.
androidNativeArm32 {
binaries {
executable {
entryPoint = "sk.ainet.apps.kgemma.cli.android.main"
baseName = "kgemma"
}
}
}

jvm {
mainRun {
mainClass.set("sk.ainet.apps.kgemma.cli.MainKt")
Expand Down Expand Up @@ -156,6 +168,16 @@ kotlin {
val macosArm64Main by getting { dependsOn(macosMain) }
val iosArm64Main by getting { dependsOn(iosMain) }
val iosSimulatorArm64Main by getting { dependsOn(iosMain) }
val androidNativeArm32Main by getting {
dependsOn(nativeMain)
dependencies {
// ToolCall/CompactCodec/FunctionGemmaChatTemplate — the compile-leg
// export (FunctionGemmaExportHarness) isn't needed on-device, so
// unlike jvmMain this doesn't pull in :llm-inference:functiongemma.
implementation(project(":llm-runtime:gemma-iree"))
implementation(project(":llm-agent"))
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package sk.ainet.apps.kgemma

import kotlinx.coroutines.runBlocking
import kotlinx.io.buffered
import kotlinx.io.files.Path
import kotlinx.io.files.SystemFileSystem
import sk.ainet.apps.llm.InferenceRuntime
import sk.ainet.apps.llm.OptimizedLLMMode
import sk.ainet.apps.llm.OptimizedLLMRuntime
import sk.ainet.apps.llm.generate
import sk.ainet.apps.llm.tokenizer.GGUFTokenizer
import sk.ainet.context.DirectCpuExecutionContext
import sk.ainet.io.gguf.createRandomAccessSource
import sk.ainet.io.model.QuantPolicy
import sk.ainet.lang.types.FP32
import sk.ainet.models.gemma.Gemma4WeightLoader
import sk.ainet.models.gemma.GemmaNetworkLoader
import sk.ainet.apps.kllama.chat.ChatMessage
import sk.ainet.apps.kllama.chat.ChatRole
import sk.ainet.transformers.gemma.iree.CompactCodec
import sk.ainet.transformers.gemma.iree.FunctionGemmaChatTemplate
import sk.ainet.transformers.gemma.iree.ToolCall
import kotlin.random.Random

/**
* androidNativeArm32's [FunctionGemma]: identical contract (`fromGguf(path).call(text)`),
* same `gemmaNetwork()` DSL + `DirectCpuExecutionContext` as the JVM facade — only the
* file-access layer differs, because `JvmRandomAccessSource`/`java.io.File` don't exist
* here. Tokenizer loading mirrors [sk.ainet.transformers.gemma.iree.GemmaKvDecoder]'s own
* native GGUF read (`SystemFileSystem`/`kotlinx-io`); weight loading uses
* `sk.ainet.io.gguf.createRandomAccessSource` — POSIX `pread` via
* `Posix32PreadRandomAccessSource` on this 32-bit target (see that class's own doc for why
* it isn't the same implementation the 64-bit natives share).
*
* No compile-leg export here (unlike the JVM facade's `exportCompiled`) — that's host
* tooling (`:llm-inference:functiongemma`), out of scope for an on-device eager CLI.
*/
public class NativeFunctionGemma private constructor(
private val runtime: InferenceRuntime<FP32>,
private val tokenizer: GGUFTokenizer,
private val bos: Int,
private val eot: Int,
private val eos: Int,
) {
public data class Turn(val text: String, val calls: List<ToolCall>)

private val chatTemplate = FunctionGemmaChatTemplate()

public fun call(userText: String, maxTokens: Int = 24): Turn {
val prompt = chatTemplate.apply(listOf(ChatMessage(ChatRole.USER, userText)))
val ptoks = tokenizer.encode(prompt)
val gen = ArrayList<Int>(maxTokens)
var stopped = false
runtime.generate(prompt = ptoks, steps = maxTokens, temperature = 0f, bosToken = bos) { id ->
if (!stopped) {
if (id == eot || id == eos) stopped = true else gen.add(id)
}
}
val text = tokenizer.decode(gen.toIntArray())
return Turn(text, CompactCodec.parse(text))
}

public companion object {
public fun fromGguf(gguf: String, partialRotary: Float = 1.0f): NativeFunctionGemma = runBlocking {
val tok = GGUFTokenizer.fromSource(SystemFileSystem.source(Path(gguf)).buffered())
val ctx = DirectCpuExecutionContext.create()
val weights = Gemma4WeightLoader(
randomAccessProvider = {
createRandomAccessSource(gguf)
?: error("could not open $gguf for random access (pread failed?)")
},
quantPolicy = QuantPolicy.DEQUANTIZE_TO_FP32,
).loadToMapStreaming<FP32, Float>(ctx, FP32::class)
val patched = weights.copy(
metadata = weights.metadata.copy(
ropeParametersFull = weights.metadata.ropeParametersFull.copy(partialRotaryFactor = partialRotary),
),
)
val model = GemmaNetworkLoader.fromWeights(ctx, patched, FP32::class)
val runtime = OptimizedLLMRuntime(model, ctx, OptimizedLLMMode.DIRECT, FP32::class, random = Random.Default)
NativeFunctionGemma(
runtime = runtime,
tokenizer = tok,
bos = tok.bosTokenId,
eot = tok.encode("<end_of_turn>").single(),
eos = tok.eosTokenId,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package sk.ainet.apps.kgemma.cli.android

import sk.ainet.apps.kgemma.NativeFunctionGemma

/**
* Real (non-stub) native CLI entry point, androidNativeArm32-only — unlike the shared
* `sk.ainet.apps.kgemma.cli.main` stub (nativeMain, used by linux/macos), this one
* actually runs [NativeFunctionGemma]. Own package so both can coexist without a
* redeclaration clash (Main.kt's `main` would otherwise collide with nativeMain's).
*
* Usage: kgemma <gguf-path> <instruction text>
*/
public fun main(args: Array<String>) {
val gguf = args.getOrNull(0)
?: run { println("usage: kgemma <gguf-path> <instruction text>"); return }
val instruction = args.drop(1).joinToString(" ")
if (instruction.isBlank()) {
println("usage: kgemma <gguf-path> <instruction text>")
return
}

val fg = NativeFunctionGemma.fromGguf(gguf)
val turn = fg.call(instruction)
println("text: ${turn.text}")
turn.calls.forEach { println("call: ${it.tool}(${it.args})") }
}
Loading