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:
94
.github/workflows/ci.yml
vendored
94
.github/workflows/ci.yml
vendored
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) :
|
||||
|
||||
@@ -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" }) {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user