Remove cloud backend — on-device Tensor chip only

Delete GeminiCloudModel.kt entirely. All inference now runs through
Gemini Nano on the Tensor G5 TPU via ML Kit, with MediaPipe as fallback
for custom local model files. No data leaves the device.

- OnDeviceModel.create() reverts to Nano → MediaPipe chain, no apiKey param
- Removed: API key UI, SharedPreferences key storage, cloud model routing
- Removed: thinking mode (cloud-only feature)
- Removed: pixel10-fast / pixel10-thinking model IDs → single "pixel10" model
- /v1/models now reports honest on-device limits (4096 ctx, 1024 output)
- CI smoke test updated: installs APK on emulator and verifies package,
  no inference tests (require real Pixel hardware with Tensor chip)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alexpolo1
2026-02-28 21:58:21 +01:00
parent 48cf8eb347
commit 598053e94a
9 changed files with 76 additions and 728 deletions

View File

@@ -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
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<map>
<string name="gemini_api_key">${GEMINI_API_KEY}</string>
</map>
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"

View File

@@ -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<OnDeviceModel.ConvMessage>,
tools: List<OnDeviceModel.ToolDef>,
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<OnDeviceModel.ConvMessage>,
tools: List<OnDeviceModel.ToolDef>,
maxTokens: Int,
temperature: Float
): String {
// Build a map from tool_call_id → function name so we can label tool results
val toolCallIdToName = mutableMapOf<String, String>()
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<OnDeviceModel.ToolCallData>()
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"
}
}

View File

@@ -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<ConvMessage>,
@@ -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<ToolCallData>? = 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) :

View File

@@ -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" }) {

View File

@@ -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<Message> = 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<Tool>? = 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<Choice>,
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<ModelInfo> = 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
)
)
)

View File

@@ -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"
}
}

View File

@@ -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
}
}
}

View File

@@ -138,37 +138,6 @@
android:gravity="center" />
</LinearLayout>
<!-- API Key Config -->
<LinearLayout
android:id="@+id/layoutApiKey"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="12dp"
app:layout_constraintTop_toBottomOf="@id/layoutPort"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/api_key_label"
android:textColor="@color/log_text"
android:textSize="13sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etApiKey"
android:layout_width="match_parent"
android:layout_height="48dp"
android:layout_marginTop="4dp"
android:hint="@string/api_key_hint"
android:inputType="textPassword"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp" />
</LinearLayout>
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
@@ -178,7 +147,7 @@
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp"
app:layout_constraintTop_toBottomOf="@id/layoutApiKey"
app:layout_constraintTop_toBottomOf="@id/layoutPort"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />

View File

@@ -22,8 +22,6 @@
<string name="btn_stop">Stop Server</string>
<string name="port_label">Port:</string>
<string name="port_default">8080</string>
<string name="api_key_label">Gemini API Key (optional, for background use)</string>
<string name="api_key_hint">AIza…</string>
<string name="requests_served">Requests served: %d</string>
<string name="request_log_label">Request Log</string>