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 - - - ${GEMINI_API_KEY} - - 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