| package com.blaze.agent.ai |
|
|
| import android.content.Context |
| import android.net.ConnectivityManager |
| import android.net.NetworkCapabilities |
| import android.util.Log |
| import kotlinx.coroutines.Dispatchers |
| import kotlinx.coroutines.withContext |
| import okhttp3.* |
| import okhttp3.MediaType.Companion.toMediaType |
| import okhttp3.RequestBody.Companion.toRequestBody |
| import org.json.JSONArray |
| import org.json.JSONObject |
| import java.io.IOException |
| import java.util.concurrent.TimeUnit |
|
|
| class LLMRouter( |
| private val context: Context, |
| private val geminiApiKey: String, |
| private val bonsaiServerUrl: String = "http://127.0.0.1:8080" |
| ) { |
| companion object { |
| private const val TAG = "BlazeLLMRouter" |
| private const val GEMINI_MODEL = "gemini-2.0-flash" |
| private const val GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models" |
| } |
|
|
| private val httpClient = OkHttpClient.Builder() |
| .connectTimeout(30, TimeUnit.SECONDS) |
| .readTimeout(120, TimeUnit.SECONDS) |
| .writeTimeout(30, TimeUnit.SECONDS) |
| .build() |
|
|
| suspend fun complete(prompt: String): String = withContext(Dispatchers.IO) { |
| if (isOnline()) { Log.d(TAG, "→ Gemini"); geminiComplete(prompt) } |
| else { Log.d(TAG, "→ Bonsai"); bonsaiComplete(prompt) } |
| } |
|
|
| suspend fun completeWithImage(prompt: String, imageBytes: ByteArray): String = withContext(Dispatchers.IO) { |
| if (isOnline()) { Log.d(TAG, "→ Gemini Vision"); geminiCompleteWithImage(prompt, imageBytes) } |
| else { Log.d(TAG, "→ Bonsai (text-only fallback)"); bonsaiComplete(prompt) } |
| } |
|
|
| fun isOnline(): Boolean { |
| val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager |
| val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false |
| return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && |
| caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) |
| } |
|
|
| private fun geminiComplete(prompt: String): String { |
| val body = 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("temperature", 0.3); put("maxOutputTokens", 1024) }) |
| } |
| val request = Request.Builder() |
| .url("$GEMINI_BASE_URL/$GEMINI_MODEL:generateContent?key=$geminiApiKey") |
| .post(body.toString().toRequestBody("application/json".toMediaType())).build() |
| return executeRequest(request) { r -> |
| JSONObject(r).getJSONArray("candidates").getJSONObject(0) |
| .getJSONObject("content").getJSONArray("parts").getJSONObject(0).getString("text") |
| } |
| } |
|
|
| private fun geminiCompleteWithImage(prompt: String, imageBytes: ByteArray): String { |
| val b64 = android.util.Base64.encodeToString(imageBytes, android.util.Base64.NO_WRAP) |
| val body = JSONObject().apply { |
| put("contents", JSONArray().apply { |
| put(JSONObject().apply { |
| put("role", "user") |
| put("parts", JSONArray().apply { |
| put(JSONObject().apply { put("inlineData", JSONObject().apply { put("mimeType", "image/png"); put("data", b64) }) }) |
| put(JSONObject().apply { put("text", prompt) }) |
| }) |
| }) |
| }) |
| } |
| val request = Request.Builder() |
| .url("$GEMINI_BASE_URL/$GEMINI_MODEL:generateContent?key=$geminiApiKey") |
| .post(body.toString().toRequestBody("application/json".toMediaType())).build() |
| return executeRequest(request) { r -> |
| JSONObject(r).getJSONArray("candidates").getJSONObject(0) |
| .getJSONObject("content").getJSONArray("parts").getJSONObject(0).getString("text") |
| } |
| } |
|
|
| private fun bonsaiComplete(prompt: String): String { |
| val body = JSONObject().apply { |
| put("model", "bonsai") |
| put("messages", JSONArray().apply { |
| put(JSONObject().apply { put("role", "system"); put("content", "You are Blaze, an AI agent that controls Android phones. Be concise and precise.") }) |
| put(JSONObject().apply { put("role", "user"); put("content", prompt) }) |
| }) |
| put("temperature", 0.3); put("max_tokens", 512); put("stream", false) |
| } |
| val request = Request.Builder() |
| .url("$bonsaiServerUrl/v1/chat/completions") |
| .post(body.toString().toRequestBody("application/json".toMediaType())).build() |
| return executeRequest(request) { r -> |
| JSONObject(r).getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content") |
| } |
| } |
|
|
| private fun executeRequest(request: Request, parse: (String) -> String): String { |
| return try { |
| httpClient.newCall(request).execute().use { response -> |
| if (!response.isSuccessful) throw IOException("HTTP ${response.code}: ${response.body?.string()}") |
| parse(response.body?.string() ?: throw IOException("Empty response")) |
| } |
| } catch (e: Exception) { Log.e(TAG, "LLM request failed", e); throw e } |
| } |
| } |
|
|