Phase 3: backends - llama.cpp (local) and cloud (OpenAI/DeepSeek/Anthropic)
Browse files
app/src/main/java/io/github/abrarul/modularai/engine/Engine.kt
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
package io.github.abrarul.modularai.engine
|
| 2 |
+
|
| 3 |
+
import org.json.JSONArray
|
| 4 |
+
import org.json.JSONObject
|
| 5 |
+
import java.net.HttpURLConnection
|
| 6 |
+
import java.net.URL
|
| 7 |
+
import java.nio.ByteBuffer
|
| 8 |
+
import java.nio.CharBuffer
|
| 9 |
+
import java.nio.charset.CodingErrorAction
|
| 10 |
+
|
| 11 |
+
interface Backend {
|
| 12 |
+
/** Streams text through [onText]; return false from it to stop. */
|
| 13 |
+
fun generate(prompt: String, maxTokens: Int, onText: (String) -> Boolean)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/** Called from native code. Keep the name and signature in sync with llama_jni.cpp and proguard. */
|
| 17 |
+
interface TokenSink {
|
| 18 |
+
fun onToken(bytes: ByteArray): Boolean
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
class LlamaEngine : Backend {
|
| 22 |
+
private var handle = 0L
|
| 23 |
+
|
| 24 |
+
private external fun nativeLoad(path: String, nCtx: Int, threads: Int): Long
|
| 25 |
+
private external fun nativeGenerate(h: Long, prompt: String, maxTokens: Int, temp: Float, sink: TokenSink)
|
| 26 |
+
private external fun nativeFree(h: Long)
|
| 27 |
+
|
| 28 |
+
val loaded get() = handle != 0L
|
| 29 |
+
|
| 30 |
+
fun load(path: String, nCtx: Int, threads: Int = Runtime.getRuntime().availableProcessors().coerceAtMost(6)) {
|
| 31 |
+
unload()
|
| 32 |
+
handle = nativeLoad(path, nCtx, threads)
|
| 33 |
+
require(handle != 0L) { "Could not load model (unsupported file, or not enough RAM)" }
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
fun unload() { if (handle != 0L) { nativeFree(handle); handle = 0L } }
|
| 37 |
+
|
| 38 |
+
override fun generate(prompt: String, maxTokens: Int, onText: (String) -> Boolean) {
|
| 39 |
+
check(handle != 0L) { "No model loaded" }
|
| 40 |
+
val dec = Charsets.UTF_8.newDecoder()
|
| 41 |
+
.onMalformedInput(CodingErrorAction.REPLACE).onUnmappableCharacter(CodingErrorAction.REPLACE)
|
| 42 |
+
var pending = ByteArray(0)
|
| 43 |
+
nativeGenerate(handle, prompt, maxTokens, 0.7f, object : TokenSink {
|
| 44 |
+
override fun onToken(bytes: ByteArray): Boolean {
|
| 45 |
+
// A token may end mid-character; decode only complete UTF-8 sequences.
|
| 46 |
+
val bb = ByteBuffer.wrap(pending + bytes)
|
| 47 |
+
val cb = CharBuffer.allocate(bb.remaining() + 4)
|
| 48 |
+
dec.decode(bb, cb, false)
|
| 49 |
+
pending = ByteArray(bb.remaining()).also { bb.get(it) }
|
| 50 |
+
cb.flip()
|
| 51 |
+
return if (cb.isEmpty()) true else onText(cb.toString())
|
| 52 |
+
}
|
| 53 |
+
})
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
companion object { init { System.loadLibrary("modularai_llama") } }
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** Non-streaming cloud fallback. [provider]: openai | deepseek | anthropic. */
|
| 60 |
+
class CloudBackend(private val provider: String, private val key: String, private val model: String) : Backend {
|
| 61 |
+
|
| 62 |
+
override fun generate(prompt: String, maxTokens: Int, onText: (String) -> Boolean) {
|
| 63 |
+
val msgs = JSONArray().put(JSONObject().put("role", "user").put("content", prompt))
|
| 64 |
+
val anthropic = provider == "anthropic"
|
| 65 |
+
val url = when (provider) {
|
| 66 |
+
"anthropic" -> "https://api.anthropic.com/v1/messages"
|
| 67 |
+
"deepseek" -> "https://api.deepseek.com/chat/completions"
|
| 68 |
+
"openai" -> "https://api.openai.com/v1/chat/completions"
|
| 69 |
+
else -> error("Unknown provider: $provider (openai | deepseek | anthropic)")
|
| 70 |
+
}
|
| 71 |
+
val body = JSONObject().put("model", model).put("max_tokens", maxTokens).put("messages", msgs)
|
| 72 |
+
val c = URL(url).openConnection() as HttpURLConnection
|
| 73 |
+
c.requestMethod = "POST"
|
| 74 |
+
c.doOutput = true
|
| 75 |
+
c.connectTimeout = 20_000
|
| 76 |
+
c.readTimeout = 120_000
|
| 77 |
+
c.setRequestProperty("Content-Type", "application/json")
|
| 78 |
+
if (anthropic) {
|
| 79 |
+
c.setRequestProperty("x-api-key", key)
|
| 80 |
+
c.setRequestProperty("anthropic-version", "2023-06-01")
|
| 81 |
+
} else c.setRequestProperty("Authorization", "Bearer $key")
|
| 82 |
+
c.outputStream.use { it.write(body.toString().toByteArray()) }
|
| 83 |
+
|
| 84 |
+
val ok = c.responseCode in 200..299
|
| 85 |
+
val text = (if (ok) c.inputStream else c.errorStream).bufferedReader().readText()
|
| 86 |
+
if (!ok) error("HTTP ${c.responseCode}: ${text.take(300)}")
|
| 87 |
+
val j = JSONObject(text)
|
| 88 |
+
val out = if (anthropic) j.getJSONArray("content").getJSONObject(0).getString("text")
|
| 89 |
+
else j.getJSONArray("choices").getJSONObject(0).getJSONObject("message").getString("content")
|
| 90 |
+
onText(out)
|
| 91 |
+
}
|
| 92 |
+
}
|