diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9ca7a80..fbcc206 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,15 +38,23 @@ jobs:
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk
- test:
- name: API Integration Tests
+ # NOTE: Full inference tests (tool calling, generation) require a real Pixel
+ # device with the Tensor chip and Gemini Nano available via AICore.
+ # The emulator has no Tensor chip — run on-device tests manually with:
+ # adb install app-debug.apk
+ # adb shell am startservice -n com.pixel10.ai/.server.ApiServerService \
+ # -a com.pixel10.ai.START_SERVER --ei port 8080
+ # adb forward tcp:8080 tcp:8080
+ # curl http://localhost:8080/health
+ install-smoke:
+ name: Install + Smoke Test (emulator)
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- - name: Enable KVM (hardware-accelerated emulator)
+ - name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
@@ -59,15 +67,6 @@ jobs:
java-version: "17"
distribution: "temurin"
- - name: Cache Gradle
- uses: actions/cache@v4
- with:
- path: |
- ~/.gradle/caches
- ~/.gradle/wrapper
- key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
- restore-keys: gradle-
-
- name: Download APK artifact
uses: actions/download-artifact@v4
with:
@@ -110,71 +109,8 @@ jobs:
- name: Install APK
run: $ANDROID_HOME/platform-tools/adb install apk/app-debug.apk
- - name: Write Gemini API key to SharedPreferences
- env:
- GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
+ - name: Verify APK installed
run: |
- $ANDROID_HOME/platform-tools/adb shell run-as com.pixel10.ai sh -c \
- 'mkdir -p /data/data/com.pixel10.ai/shared_prefs && cat > /data/data/com.pixel10.ai/shared_prefs/pixel10_prefs.xml' << EOF
-
-
- EOF
-
- - name: Start API server
- run: |
- $ANDROID_HOME/platform-tools/adb shell am startservice \
- -n com.pixel10.ai/.server.ApiServerService \
- -a com.pixel10.ai.START_SERVER \
- --ei port 8080
- sleep 5
-
- - name: Forward device port
- run: $ANDROID_HOME/platform-tools/adb forward tcp:8080 tcp:8080
-
- - name: Wait for server to be ready
- run: |
- timeout 30 bash -c '
- until curl -sf http://localhost:8080/health > /dev/null 2>&1; do
- sleep 2
- done
- '
-
- - name: Test /health
- run: |
- RESPONSE=$(curl -sf http://localhost:8080/health)
- echo "Health response: $RESPONSE"
- echo "$RESPONSE" | grep -q '"status"' || \
- (echo "FAIL: /health did not return expected JSON" && exit 1)
-
- - name: Test /v1/models
- run: |
- RESPONSE=$(curl -sf http://localhost:8080/v1/models)
- echo "Models response: $RESPONSE"
- echo "$RESPONSE" | grep -q '"data"' || \
- (echo "FAIL: /v1/models did not return expected JSON" && exit 1)
-
- - name: Test /v1/chat/completions (non-streaming)
- run: |
- RESPONSE=$(curl -sf http://localhost:8080/v1/chat/completions \
- -H "Content-Type: application/json" \
- -d '{"model":"gemini","messages":[{"role":"user","content":"Reply with one word: hello"}]}')
- echo "Chat response: $RESPONSE"
- echo "$RESPONSE" | grep -q '"choices"' || \
- (echo "FAIL: /v1/chat/completions did not return choices" && exit 1)
-
- - name: Test /v1/chat/completions (streaming)
- run: |
- # Collect SSE chunks with a 20-second timeout
- CHUNKS=$(curl -sf --max-time 20 http://localhost:8080/v1/chat/completions \
- -H "Content-Type: application/json" \
- -d '{"model":"gemini","stream":true,"messages":[{"role":"user","content":"Say hi"}]}')
- echo "Streaming chunks received:"
- echo "$CHUNKS"
- echo "$CHUNKS" | grep -q "data:" || \
- (echo "FAIL: streaming returned no SSE chunks" && exit 1)
-
- - name: Print logcat on failure
- if: failure()
- run: $ANDROID_HOME/platform-tools/adb logcat -d -s Pixel10AI ApiServerService GeminiCloudModel
+ $ANDROID_HOME/platform-tools/adb shell pm list packages | grep com.pixel10.ai \
+ || (echo "FAIL: APK not installed" && exit 1)
+ echo "PASS: APK installed successfully"
diff --git a/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt b/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt
deleted file mode 100644
index 8c9322f..0000000
--- a/app/src/main/java/com/pixel10/ai/inference/GeminiCloudModel.kt
+++ /dev/null
@@ -1,451 +0,0 @@
-package com.pixel10.ai.inference
-
-import android.util.Log
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import org.json.JSONArray
-import org.json.JSONObject
-import java.io.BufferedReader
-import java.io.InputStreamReader
-import java.net.HttpURLConnection
-import java.net.URL
-
-/**
- * Cloud backend that proxies inference requests to the Gemini 2.0 Flash API.
- *
- * Works from any context (foreground service, background) because it uses
- * standard HTTPS rather than the AICore system service. This is the fallback
- * when Gemini Nano is unavailable (emulator, background inference blocked, etc.).
- *
- * Requires a Gemini API key (free tier available at ai.google.dev).
- */
-class GeminiCloudModel(private val apiKey: String) : OnDeviceModel {
-
- override val backendName = "Gemini 2.0 Flash (Cloud)"
-
- override val isReady: Boolean = true
-
- override suspend fun generate(
- prompt: String,
- maxTokens: Int,
- temperature: Float
- ): String = withContext(Dispatchers.IO) {
- val url = URL("$BASE_URL:generateContent?key=$apiKey")
- val connection = url.openConnection() as HttpURLConnection
- try {
- connection.requestMethod = "POST"
- connection.setRequestProperty("Content-Type", "application/json")
- connection.doOutput = true
- connection.connectTimeout = 30_000
- connection.readTimeout = 60_000
-
- val body = buildRequestBody(prompt, maxTokens, temperature)
- connection.outputStream.use { it.write(body.toByteArray()) }
-
- val responseCode = connection.responseCode
- if (responseCode != HttpURLConnection.HTTP_OK) {
- val error = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
- throw OnDeviceModel.InferenceException("Gemini API error $responseCode: $error")
- }
-
- val responseText = connection.inputStream.bufferedReader().readText()
- parseGenerateResponse(responseText)
- } finally {
- connection.disconnect()
- }
- }
-
- override suspend fun generateStreaming(
- prompt: String,
- onToken: (String) -> Unit
- ): String = withContext(Dispatchers.IO) {
- val url = URL("$STREAMING_URL?key=$apiKey")
- val connection = url.openConnection() as HttpURLConnection
- try {
- connection.requestMethod = "POST"
- connection.setRequestProperty("Content-Type", "application/json")
- connection.setRequestProperty("Accept", "text/event-stream")
- connection.doOutput = true
- connection.connectTimeout = 30_000
- connection.readTimeout = 120_000
-
- val body = buildRequestBody(prompt, maxTokens = 1024, temperature = 0.7f)
- connection.outputStream.use { it.write(body.toByteArray()) }
-
- val responseCode = connection.responseCode
- if (responseCode != HttpURLConnection.HTTP_OK) {
- val error = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
- throw OnDeviceModel.InferenceException("Gemini streaming API error $responseCode: $error")
- }
-
- val fullText = StringBuilder()
- BufferedReader(InputStreamReader(connection.inputStream)).use { reader ->
- var line: String?
- while (reader.readLine().also { line = it } != null) {
- val l = line!!
- if (!l.startsWith("data: ")) continue
- val data = l.removePrefix("data: ").trim()
- if (data == "[DONE]") break
- try {
- val token = parseChunkText(data)
- if (token.isNotEmpty()) {
- onToken(token)
- fullText.append(token)
- }
- } catch (e: Exception) {
- Log.w(TAG, "Failed to parse SSE chunk: $data", e)
- }
- }
- }
-
- fullText.toString()
- } finally {
- connection.disconnect()
- }
- }
-
- override suspend fun chat(
- messages: List,
- tools: List,
- maxTokens: Int,
- temperature: Float
- ): OnDeviceModel.ChatResult = withContext(Dispatchers.IO) {
- val url = URL("$BASE_URL:generateContent?key=$apiKey")
- val connection = url.openConnection() as HttpURLConnection
- try {
- connection.requestMethod = "POST"
- connection.setRequestProperty("Content-Type", "application/json")
- connection.doOutput = true
- connection.connectTimeout = 30_000
- connection.readTimeout = 120_000
-
- val body = buildChatRequestBody(messages, tools, maxTokens, temperature)
- connection.outputStream.use { it.write(body.toByteArray()) }
-
- val responseCode = connection.responseCode
- if (responseCode != HttpURLConnection.HTTP_OK) {
- val error = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
- throw OnDeviceModel.InferenceException("Gemini chat API error $responseCode: $error")
- }
-
- val responseText = connection.inputStream.bufferedReader().readText()
- parseChatResponse(responseText)
- } finally {
- connection.disconnect()
- }
- }
-
- override suspend fun generateWithThinking(
- prompt: String,
- maxTokens: Int,
- thinkingBudget: Int
- ): OnDeviceModel.ThinkingResult = withContext(Dispatchers.IO) {
- val url = URL("$THINKING_URL?key=$apiKey")
- val connection = url.openConnection() as HttpURLConnection
- try {
- connection.requestMethod = "POST"
- connection.setRequestProperty("Content-Type", "application/json")
- connection.doOutput = true
- connection.connectTimeout = 30_000
- connection.readTimeout = 120_000
-
- val body = buildThinkingRequestBody(prompt, maxTokens, thinkingBudget)
- connection.outputStream.use { it.write(body.toByteArray()) }
-
- val responseCode = connection.responseCode
- if (responseCode != HttpURLConnection.HTTP_OK) {
- val error = connection.errorStream?.bufferedReader()?.readText() ?: "Unknown error"
- throw OnDeviceModel.InferenceException("Gemini thinking API error $responseCode: $error")
- }
-
- val responseText = connection.inputStream.bufferedReader().readText()
- parseThinkingResponse(responseText)
- } finally {
- connection.disconnect()
- }
- }
-
- override fun close() {
- // No resources to clean up
- }
-
- // ── Chat / Tool-calling helpers ───────────────────────────────────────────
-
- private fun buildChatRequestBody(
- messages: List,
- tools: List,
- maxTokens: Int,
- temperature: Float
- ): String {
- // Build a map from tool_call_id → function name so we can label tool results
- val toolCallIdToName = mutableMapOf()
- for (msg in messages) {
- msg.toolCalls?.forEach { tc -> toolCallIdToName[tc.id] = tc.name }
- }
-
- return JSONObject().apply {
- // System instruction (Gemini uses a dedicated field, not a role)
- val systemMsg = messages.firstOrNull { it.role == "system" }
- if (systemMsg?.content != null) {
- put("systemInstruction", JSONObject().apply {
- put("parts", JSONArray().apply {
- put(JSONObject().apply { put("text", systemMsg.content) })
- })
- })
- }
-
- // Conversation turns (skip system — handled above)
- put("contents", JSONArray().apply {
- for (msg in messages) {
- when (msg.role) {
- "system" -> { /* handled via systemInstruction */ }
-
- "user" -> put(JSONObject().apply {
- put("role", "user")
- put("parts", JSONArray().apply {
- put(JSONObject().apply { put("text", msg.content ?: "") })
- })
- })
-
- "assistant" -> {
- if (msg.toolCalls != null) {
- // Model requested tool calls
- put(JSONObject().apply {
- put("role", "model")
- put("parts", JSONArray().apply {
- for (tc in msg.toolCalls) {
- put(JSONObject().apply {
- put("functionCall", JSONObject().apply {
- put("name", tc.name)
- put("args", safeJsonObject(tc.argsJson))
- })
- })
- }
- })
- })
- } else {
- put(JSONObject().apply {
- put("role", "model")
- put("parts", JSONArray().apply {
- put(JSONObject().apply { put("text", msg.content ?: "") })
- })
- })
- }
- }
-
- "tool" -> {
- // Tool result — Gemini expects a user-role functionResponse
- val fnName = msg.toolName
- ?: toolCallIdToName[msg.toolCallId]
- ?: "unknown"
- put(JSONObject().apply {
- put("role", "user")
- put("parts", JSONArray().apply {
- put(JSONObject().apply {
- put("functionResponse", JSONObject().apply {
- put("name", fnName)
- put("response", JSONObject().apply {
- put("result", msg.content ?: "")
- })
- })
- })
- })
- })
- }
- }
- }
- })
-
- // Tool declarations
- if (tools.isNotEmpty()) {
- put("tools", JSONArray().apply {
- put(JSONObject().apply {
- put("functionDeclarations", JSONArray().apply {
- for (tool in tools) {
- put(JSONObject().apply {
- put("name", tool.name)
- put("description", tool.description)
- if (tool.parametersJson != null) {
- put("parameters", safeJsonObject(tool.parametersJson))
- }
- })
- }
- })
- })
- })
- put("toolConfig", JSONObject().apply {
- put("functionCallingConfig", JSONObject().apply {
- put("mode", "AUTO")
- })
- })
- }
-
- put("generationConfig", JSONObject().apply {
- put("maxOutputTokens", maxTokens)
- put("temperature", temperature.toDouble())
- })
- }.toString()
- }
-
- private fun parseChatResponse(json: String): OnDeviceModel.ChatResult {
- return try {
- val candidate = JSONObject(json)
- .getJSONArray("candidates")
- .getJSONObject(0)
- val content = candidate.getJSONObject("content")
- val parts = content.getJSONArray("parts")
- val finishReason = candidate.optString("finishReason", "STOP")
-
- // Check if any part is a function call
- val toolCalls = mutableListOf()
- val textBuilder = StringBuilder()
-
- for (i in 0 until parts.length()) {
- val part = parts.getJSONObject(i)
- when {
- part.has("functionCall") -> {
- val fc = part.getJSONObject("functionCall")
- toolCalls += OnDeviceModel.ToolCallData(
- id = "call_${System.currentTimeMillis()}_$i",
- name = fc.getString("name"),
- argsJson = fc.optJSONObject("args")?.toString() ?: "{}"
- )
- }
- part.has("text") -> textBuilder.append(part.getString("text"))
- }
- }
-
- when {
- toolCalls.isNotEmpty() -> OnDeviceModel.ChatResult(
- toolCalls = toolCalls,
- finishReason = "tool_calls"
- )
- else -> OnDeviceModel.ChatResult(
- content = textBuilder.toString(),
- finishReason = if (finishReason == "MAX_TOKENS") "length" else "stop"
- )
- }
- } catch (e: Exception) {
- throw OnDeviceModel.InferenceException("Failed to parse Gemini chat response: ${e.message}", e)
- }
- }
-
- /** Parse a JSON string into a JSONObject, returning an empty object on failure. */
- private fun safeJsonObject(json: String): JSONObject = try {
- JSONObject(json)
- } catch (_: Exception) {
- JSONObject()
- }
-
- private fun buildThinkingRequestBody(prompt: String, maxTokens: Int, thinkingBudget: Int): String {
- return JSONObject().apply {
- put("contents", JSONArray().apply {
- put(JSONObject().apply {
- put("role", "user")
- put("parts", JSONArray().apply {
- put(JSONObject().apply { put("text", prompt) })
- })
- })
- })
- put("generationConfig", JSONObject().apply {
- put("maxOutputTokens", maxTokens)
- put("thinkingConfig", JSONObject().apply {
- put("thinkingBudget", thinkingBudget)
- })
- })
- }.toString()
- }
-
- private fun parseThinkingResponse(json: String): OnDeviceModel.ThinkingResult {
- return try {
- val parts = JSONObject(json)
- .getJSONArray("candidates")
- .getJSONObject(0)
- .getJSONObject("content")
- .getJSONArray("parts")
-
- val thinkingBuilder = StringBuilder()
- val responseBuilder = StringBuilder()
-
- for (i in 0 until parts.length()) {
- val part = parts.getJSONObject(i)
- val text = part.optString("text", "")
- if (part.optBoolean("thought", false)) {
- thinkingBuilder.append(text)
- } else {
- responseBuilder.append(text)
- }
- }
-
- OnDeviceModel.ThinkingResult(
- thinking = thinkingBuilder.toString(),
- response = responseBuilder.toString()
- )
- } catch (e: Exception) {
- throw OnDeviceModel.InferenceException(
- "Failed to parse Gemini thinking response: ${e.message}", e
- )
- }
- }
-
- private fun buildRequestBody(prompt: String, maxTokens: Int, temperature: Float): String {
- return JSONObject().apply {
- put("contents", JSONArray().apply {
- put(JSONObject().apply {
- put("role", "user")
- put("parts", JSONArray().apply {
- put(JSONObject().apply {
- put("text", prompt)
- })
- })
- })
- })
- put("generationConfig", JSONObject().apply {
- put("maxOutputTokens", maxTokens)
- put("temperature", temperature.toDouble())
- })
- }.toString()
- }
-
- private fun parseGenerateResponse(json: String): String {
- return try {
- JSONObject(json)
- .getJSONArray("candidates")
- .getJSONObject(0)
- .getJSONObject("content")
- .getJSONArray("parts")
- .getJSONObject(0)
- .getString("text")
- } catch (e: Exception) {
- throw OnDeviceModel.InferenceException("Failed to parse Gemini response: ${e.message}", e)
- }
- }
-
- private fun parseChunkText(json: String): String {
- return try {
- JSONObject(json)
- .getJSONArray("candidates")
- .getJSONObject(0)
- .getJSONObject("content")
- .getJSONArray("parts")
- .getJSONObject(0)
- .optString("text", "")
- } catch (_: Exception) {
- ""
- }
- }
-
- companion object {
- private const val TAG = "GeminiCloudModel"
- private const val API_ROOT = "https://generativelanguage.googleapis.com/v1beta/models"
-
- // Fast model — low latency, no reasoning trace
- private const val FAST_MODEL = "gemini-2.0-flash"
- private const val BASE_URL = "$API_ROOT/$FAST_MODEL"
- private const val STREAMING_URL = "$BASE_URL:streamGenerateContent?alt=sse"
-
- // Thinking model — step-by-step reasoning before answering
- private const val THINKING_MODEL = "gemini-2.5-flash-preview-04-17"
- private const val THINKING_URL = "$API_ROOT/$THINKING_MODEL:generateContent"
- }
-}
diff --git a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
index a0eb786..2a34aaa 100644
--- a/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
+++ b/app/src/main/java/com/pixel10/ai/inference/OnDeviceModel.kt
@@ -6,20 +6,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
- * Unified interface for on-device AI inference on the Pixel 10.
+ * Unified interface for on-device AI inference on the Pixel 10's Tensor chip.
*
- * Supports three backends, tried in order:
- * 1. **Gemini Nano** via ML Kit Prompt API — uses the system-managed model
- * through AICore, accelerated by the Tensor G5 TPU. Zero setup needed
- * on supported Pixel devices. Requires foreground context.
- * 2. **Gemini Cloud** — proxies to Gemini 2.0 Flash via HTTPS. Works from
- * any context (background, emulator). Requires an API key.
- * 3. **MediaPipe LLM** — for custom open-weight models (Gemma 2B/3n, etc.)
- * that you supply yourself. Place the .bin/.task file in the app's
- * files directory.
+ * Backends (tried in order):
+ * 1. **Gemini Nano** via ML Kit Prompt API — system-managed model accelerated
+ * by the Tensor G5 TPU through AICore. Zero setup on supported Pixel devices.
+ * 2. **MediaPipe LLM** — for custom open-weight models (Gemma 2B/3n, etc.)
+ * placed in the app's files directory.
*
- * Pass [apiKey] to enable the cloud backend. If an API key is provided,
- * cloud is preferred over Nano to guarantee background operation.
+ * All inference is fully on-device. No data leaves the phone.
*/
interface OnDeviceModel {
val backendName: String
@@ -27,7 +22,7 @@ interface OnDeviceModel {
suspend fun generate(
prompt: String,
- maxTokens: Int = 1024,
+ maxTokens: Int = 8192,
temperature: Float = 0.7f
): String
@@ -36,32 +31,14 @@ interface OnDeviceModel {
onToken: (String) -> Unit
): String
- /**
- * Generate with extended thinking. Returns a [ThinkingResult] containing
- * the model's reasoning trace and its final answer separately.
- *
- * The default implementation delegates to [generate] with an empty thinking trace,
- * so backends that don't support thinking still work transparently.
- */
- suspend fun generateWithThinking(
- prompt: String,
- maxTokens: Int = 16384,
- thinkingBudget: Int = 8192
- ): ThinkingResult = ThinkingResult(thinking = "", response = generate(prompt, maxTokens))
-
/**
* Multi-turn conversation with optional tool/function calling.
*
- * Accepts the full message history so the model can reference prior turns,
- * and an optional list of tool definitions the model may invoke.
+ * Accepts the full message history and an optional list of tools.
+ * Returns a [ChatResult] which is either a text reply or a tool invocation.
*
- * Returns a [ChatResult] which is either:
- * - A text reply ([ChatResult.content] set, [ChatResult.toolCalls] null)
- * - A tool invocation ([ChatResult.toolCalls] set, [ChatResult.content] null)
- *
- * The default implementation flattens the conversation to a plain prompt
- * and calls [generate], so backends without native tool support still work
- * (they just won't invoke tools).
+ * Default implementation flattens the conversation to a prompt and calls
+ * [generate], so all backends work transparently.
*/
suspend fun chat(
messages: List,
@@ -71,11 +48,11 @@ interface OnDeviceModel {
): ChatResult {
val prompt = messages.joinToString("\n") { msg ->
when (msg.role) {
- "system" -> "System: ${msg.content.orEmpty()}"
- "user" -> "User: ${msg.content.orEmpty()}"
+ "system" -> "System: ${msg.content.orEmpty()}"
+ "user" -> "User: ${msg.content.orEmpty()}"
"assistant" -> "Assistant: ${msg.content.orEmpty()}"
- "tool" -> "Tool result: ${msg.content.orEmpty()}"
- else -> "${msg.role}: ${msg.content.orEmpty()}"
+ "tool" -> "Tool result: ${msg.content.orEmpty()}"
+ else -> "${msg.role}: ${msg.content.orEmpty()}"
}
} + "\nAssistant:"
return ChatResult(content = generate(prompt, maxTokens, temperature))
@@ -85,13 +62,6 @@ interface OnDeviceModel {
// ── Supporting types ──────────────────────────────────────────────────────
- data class ThinkingResult(
- /** The model's internal reasoning trace (may be empty for non-thinking backends). */
- val thinking: String,
- /** The final answer shown to the user. */
- val response: String
- )
-
/** A single message in a multi-turn conversation passed to [chat]. */
data class ConvMessage(
val role: String,
@@ -100,7 +70,7 @@ interface OnDeviceModel {
val toolCalls: List? = null,
/** For role=tool messages: the tool_call id being responded to. */
val toolCallId: String? = null,
- /** For role=tool messages: the function name (needed by Gemini). */
+ /** For role=tool messages: the function name. */
val toolName: String? = null
)
@@ -131,58 +101,41 @@ interface OnDeviceModel {
private const val TAG = "OnDeviceModel"
/**
- * Create the best available model.
- *
- * Fallback chain:
- * 1. GeminiNano — on-device, best quality, foreground only
- * 2. GeminiCloud — network, always works (background + CI); needs [apiKey]
- * 3. MediaPipe — local model file, fully offline
- *
- * If [apiKey] is non-empty, cloud is tried *before* Nano so the server
- * stays responsive after the user navigates away from the app.
+ * Create the best available on-device model.
+ * Tries Gemini Nano (Tensor G5 TPU) first, falls back to MediaPipe.
*/
- suspend fun create(context: Context, apiKey: String = ""): OnDeviceModel =
- withContext(Dispatchers.IO) {
- val hasKey = apiKey.isNotBlank()
-
- // Prefer cloud when an API key is available — guarantees background operation
- if (hasKey) {
- Log.i(TAG, "API key set — using Gemini Cloud for background-safe inference")
- return@withContext GeminiCloudModel(apiKey)
- }
-
- // Try Gemini Nano via ML Kit Prompt API
- try {
- Log.i(TAG, "Attempting Gemini Nano via ML Kit Prompt API...")
- val nano = GeminiNanoModel.create(context)
- Log.i(TAG, "Gemini Nano ready!")
- return@withContext nano
- } catch (e: Exception) {
- Log.w(TAG, "Gemini Nano not available: ${e.message}")
- }
-
- // Fall back to MediaPipe with a local model file
- try {
- Log.i(TAG, "Attempting MediaPipe LLM with local model...")
- val mediapipe = MediaPipeModel.create(context)
- Log.i(TAG, "MediaPipe model ready!")
- return@withContext mediapipe
- } catch (e: Exception) {
- Log.w(TAG, "MediaPipe model not available: ${e.message}")
- }
-
- throw InferenceException(
- "No AI model available.\n\n" +
- "Option 1: Enter a Gemini API key in the app (works everywhere)\n\n" +
- "Option 2: Use a Pixel device with Gemini Nano support " +
- "(Pixel 10/9/8 series)\n\n" +
- "Option 3: Place a MediaPipe-compatible model (.bin or .task) in:\n" +
- " ${context.filesDir.absolutePath}/\n" +
- " Supported: gemma-3n-E2B.task, gemma-2b-it-gpu-int4.bin, etc.\n\n" +
- "Download models from:\n" +
- " https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android"
- )
+ suspend fun create(context: Context): OnDeviceModel = withContext(Dispatchers.IO) {
+ // Try Gemini Nano via ML Kit Prompt API
+ try {
+ Log.i(TAG, "Attempting Gemini Nano via ML Kit Prompt API...")
+ val nano = GeminiNanoModel.create(context)
+ Log.i(TAG, "Gemini Nano ready!")
+ return@withContext nano
+ } catch (e: Exception) {
+ Log.w(TAG, "Gemini Nano not available: ${e.message}")
}
+
+ // Fall back to MediaPipe with a local model file
+ try {
+ Log.i(TAG, "Attempting MediaPipe LLM with local model...")
+ val mediapipe = MediaPipeModel.create(context)
+ Log.i(TAG, "MediaPipe model ready!")
+ return@withContext mediapipe
+ } catch (e: Exception) {
+ Log.w(TAG, "MediaPipe model not available: ${e.message}")
+ }
+
+ throw InferenceException(
+ "No on-device AI model available.\n\n" +
+ "Option 1: Use a Pixel device with Gemini Nano support " +
+ "(Pixel 10/9/8 series)\n\n" +
+ "Option 2: Place a MediaPipe-compatible model (.bin or .task) in:\n" +
+ " ${context.filesDir.absolutePath}/\n" +
+ " Supported: gemma-3n-E2B.task, gemma-2b-it-gpu-int4.bin, etc.\n\n" +
+ "Download models from:\n" +
+ " https://ai.google.dev/edge/mediapipe/solutions/genai/llm_inference/android"
+ )
+ }
}
class InferenceException(message: String, cause: Throwable? = null) :
diff --git a/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt b/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt
index 60814be..7345b22 100644
--- a/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt
+++ b/app/src/main/java/com/pixel10/ai/server/AIApiServer.kt
@@ -88,29 +88,9 @@ class AIApiServer(
}
val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}"
- val useThinking = request.thinking_budget > 0 || request.model.contains("think")
val hasTools = !request.tools.isNullOrEmpty()
- log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, thinking=$useThinking, stream=${request.stream}")
-
- // ── Thinking mode ──────────────────────────────────────────────────────
- if (useThinking) {
- val prompt = buildFlatPrompt(request.messages)
- val budget = if (request.thinking_budget > 0) request.thinking_budget else 8192
- val result = runBlocking {
- model.generateWithThinking(prompt, request.max_tokens, budget)
- }
- log("Thinking: ${result.thinking.take(80)}...")
- log("Response: ${result.response.take(80)}...")
- return jsonResponse(200, gson.toJson(ChatResponse(
- id = id, model = request.model,
- choices = listOf(Choice(
- message = Message(role = "assistant", content = result.response),
- thinking = result.thinking.ifEmpty { null }
- )),
- usage = buildUsage(result.response, result.response)
- )))
- }
+ log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, stream=${request.stream}")
// ── Tool calling / multi-turn chat ─────────────────────────────────────
if (hasTools || request.messages.size > 1 || request.messages.any { it.role == "system" }) {
diff --git a/app/src/main/java/com/pixel10/ai/server/ApiModels.kt b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt
index 0a6d8b9..2c55da2 100644
--- a/app/src/main/java/com/pixel10/ai/server/ApiModels.kt
+++ b/app/src/main/java/com/pixel10/ai/server/ApiModels.kt
@@ -15,15 +15,12 @@ import com.google.gson.annotations.SerializedName
// ── Requests ──────────────────────────────────────────────────────────────────
data class ChatRequest(
- val model: String = "pixel10-fast",
+ val model: String = "pixel10",
val messages: List = emptyList(),
val prompt: String? = null,
- /** Output token limit. Defaults to 8192 — enough for full functions/files. */
val max_tokens: Int = 8192,
val temperature: Float = 0.7f,
val stream: Boolean = false,
- /** Thinking token budget. 0 = fast (no thinking). >0 = thinking mode. */
- val thinking_budget: Int = 0,
/** Tool/function definitions available to the model. */
val tools: List? = null,
/** "auto" | "none" | "required" — defaults to "auto" when tools are provided. */
@@ -73,7 +70,7 @@ data class ChatResponse(
@SerializedName("object")
val objectType: String = "chat.completion",
val created: Long = System.currentTimeMillis() / 1000,
- val model: String = "pixel10-fast",
+ val model: String = "pixel10",
val choices: List,
val usage: Usage
)
@@ -82,9 +79,7 @@ data class Choice(
val index: Int = 0,
val message: Message,
/** "stop" | "tool_calls" | "length" */
- val finish_reason: String = "stop",
- /** Non-standard: reasoning trace, present only in thinking mode. */
- val thinking: String? = null
+ val finish_reason: String = "stop"
)
data class Usage(
@@ -134,16 +129,10 @@ data class ModelList(
val objectType: String = "list",
val data: List = listOf(
ModelInfo(
- id = "pixel10-fast",
- description = "Gemini 2.0 Flash — 1M context, tool calling, streaming. Best for agent tasks.",
- context_length = 1_000_000,
- max_output_tokens = 8192
- ),
- ModelInfo(
- id = "pixel10-thinking",
- description = "Gemini 2.5 Flash — 1M context, extended reasoning before answering.",
- context_length = 1_000_000,
- max_output_tokens = 16384
+ id = "pixel10",
+ description = "Gemini Nano on Tensor G5 — fully on-device, private, tool calling supported.",
+ context_length = 4096,
+ max_output_tokens = 1024
)
)
)
diff --git a/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt b/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt
index 3110172..04ce35e 100644
--- a/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt
+++ b/app/src/main/java/com/pixel10/ai/server/ApiServerService.kt
@@ -3,7 +3,6 @@ package com.pixel10.ai.server
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
-import android.content.Context
import android.content.Intent
import android.os.Binder
import android.os.IBinder
@@ -65,14 +64,8 @@ class ApiServerService : Service() {
scope.launch {
try {
- // Load the AI model — prefer cloud if an API key is configured
- val apiKey = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- .getString(PREF_API_KEY, "") ?: ""
- notifyLog("Loading AI model...")
- if (apiKey.isNotBlank()) {
- notifyLog("API key configured — using cloud backend")
- }
- model = OnDeviceModel.create(applicationContext, apiKey)
+ notifyLog("Loading on-device AI model...")
+ model = OnDeviceModel.create(applicationContext)
notifyLog("Model ready: ${model!!.backendName}")
// Start the HTTP server
@@ -167,7 +160,5 @@ class ApiServerService : Service() {
const val EXTRA_PORT = "port"
const val DEFAULT_PORT = 8080
private const val NOTIFICATION_ID = 1
- const val PREFS_NAME = "pixel10_prefs"
- const val PREF_API_KEY = "gemini_api_key"
}
}
diff --git a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
index 12f9102..b6b0305 100644
--- a/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
+++ b/app/src/main/java/com/pixel10/ai/ui/MainActivity.kt
@@ -18,8 +18,6 @@ import androidx.core.content.ContextCompat
import com.pixel10.ai.R
import com.pixel10.ai.databinding.ActivityMainBinding
import com.pixel10.ai.server.ApiServerService
-import com.pixel10.ai.server.ApiServerService.Companion.PREF_API_KEY
-import com.pixel10.ai.server.ApiServerService.Companion.PREFS_NAME
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -67,10 +65,6 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermission()
- // Restore saved API key
- val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- binding.etApiKey.setText(prefs.getString(PREF_API_KEY, ""))
-
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) {
stopServer()
@@ -119,13 +113,6 @@ class MainActivity : AppCompatActivity() {
private fun startServer() {
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
- // Persist API key before starting the service
- val apiKey = binding.etApiKey.text.toString().trim()
- getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
- .edit()
- .putString(PREF_API_KEY, apiKey)
- .apply()
-
val intent = Intent(this, ApiServerService::class.java).apply {
action = ApiServerService.ACTION_START
putExtra(ApiServerService.EXTRA_PORT, port)
@@ -159,7 +146,6 @@ class MainActivity : AppCompatActivity() {
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
- binding.etApiKey.isEnabled = true
}
ApiServerService.ServerState.LOADING_MODEL -> {
binding.tvServerStatus.text = getString(R.string.server_status_starting)
@@ -167,7 +153,6 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = getString(R.string.model_loading)
binding.btnToggle.isEnabled = false
binding.etPort.isEnabled = false
- binding.etApiKey.isEnabled = false
}
ApiServerService.ServerState.RUNNING -> {
val port = binding.etPort.text.toString()
@@ -179,7 +164,6 @@ class MainActivity : AppCompatActivity() {
binding.btnToggle.text = getString(R.string.btn_stop)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = false
- binding.etApiKey.isEnabled = false
}
ApiServerService.ServerState.ERROR -> {
binding.tvServerStatus.text = getString(R.string.server_status_error)
@@ -188,7 +172,6 @@ class MainActivity : AppCompatActivity() {
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.etPort.isEnabled = true
- binding.etApiKey.isEnabled = true
}
}
}
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 28e7bdb..0163ff0 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -138,37 +138,6 @@
android:gravity="center" />
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6843130..768c684 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -22,8 +22,6 @@
Stop Server
Port:
8080
- Gemini API Key (optional, for background use)
- AIza…
Requests served: %d
Request Log