ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / OpenAiCompatibleProvider.kt
OpenAiCompatibleProvider.kt
| 1 | package net.fuzzyworld.aichat |
| 2 | |
| 3 | import okhttp3.Call |
| 4 | import okhttp3.Callback |
| 5 | import okhttp3.MediaType.Companion.toMediaType |
| 6 | import okhttp3.Request |
| 7 | import okhttp3.RequestBody.Companion.toRequestBody |
| 8 | import okhttp3.Response |
| 9 | import org.json.JSONArray |
| 10 | import org.json.JSONObject |
| 11 | import java.io.IOException |
| 12 | |
| 13 | abstract class OpenAiCompatibleProvider : AiProvider { |
| 14 | override val supportsWebSearch = false |
| 15 | override val allowsBaseUrlOverride = false |
| 16 | |
| 17 | override fun ask( |
| 18 | apiKey: String, |
| 19 | baseUrl: String, |
| 20 | model: String, |
| 21 | question: String, |
| 22 | onResult: (String?) -> Unit, |
| 23 | ) { |
| 24 | val payload = JSONObject().apply { |
| 25 | put("model", model) |
| 26 | put("max_tokens", 1024) |
| 27 | put("messages", JSONArray().apply { |
| 28 | put(JSONObject().apply { |
| 29 | put("role", "system") |
| 30 | put("content", SYSTEM_PROMPT) |
| 31 | }) |
| 32 | put(JSONObject().apply { |
| 33 | put("role", "user") |
| 34 | put("content", question) |
| 35 | }) |
| 36 | }) |
| 37 | }.toString().toRequestBody("application/json".toMediaType()) |
| 38 | |
| 39 | val request = Request.Builder() |
| 40 | .url("$baseUrl/v1/chat/completions") |
| 41 | .post(payload) |
| 42 | .addHeader("Authorization", "Bearer $apiKey") |
| 43 | .build() |
| 44 | |
| 45 | Http.client.newCall(request).enqueue(object : Callback { |
| 46 | override fun onFailure(call: Call, e: IOException) = onResult(null) |
| 47 | override fun onResponse(call: Call, response: Response) { |
| 48 | response.use { |
| 49 | if (!it.isSuccessful) { onResult(null); return } |
| 50 | try { |
| 51 | val text = JSONObject(it.body!!.string()) |
| 52 | .getJSONArray("choices") |
| 53 | .getJSONObject(0) |
| 54 | .getJSONObject("message") |
| 55 | .getString("content") |
| 56 | onResult(text.trim().ifEmpty { null }) |
| 57 | } catch (_: Exception) { onResult(null) } |
| 58 | } |
| 59 | } |
| 60 | }) |
| 61 | } |
| 62 | |
| 63 | override fun listModels( |
| 64 | apiKey: String, |
| 65 | baseUrl: String, |
| 66 | onResult: (List<ModelInfo>?) -> Unit, |
| 67 | ) { |
| 68 | val request = Request.Builder() |
| 69 | .url("$baseUrl/v1/models") |
| 70 | .get() |
| 71 | .addHeader("Authorization", "Bearer $apiKey") |
| 72 | .build() |
| 73 | |
| 74 | Http.client.newCall(request).enqueue(object : Callback { |
| 75 | override fun onFailure(call: Call, e: IOException) = onResult(null) |
| 76 | override fun onResponse(call: Call, response: Response) { |
| 77 | response.use { |
| 78 | if (!it.isSuccessful) { onResult(null); return } |
| 79 | try { |
| 80 | val data = JSONObject(it.body!!.string()).getJSONArray("data") |
| 81 | val models = (0 until data.length()) |
| 82 | .map { i -> data.getJSONObject(i) } |
| 83 | .filter { obj -> filterModel(obj) } |
| 84 | .map { obj -> |
| 85 | val mid = obj.getString("id") |
| 86 | ModelInfo( |
| 87 | id = mid, |
| 88 | displayName = obj.optString("name", mid), |
| 89 | ) |
| 90 | } |
| 91 | .sortedBy { it.id } |
| 92 | onResult(models) |
| 93 | } catch (_: Exception) { onResult(null) } |
| 94 | } |
| 95 | } |
| 96 | }) |
| 97 | } |
| 98 | |
| 99 | /** Override to restrict which models appear in the list. */ |
| 100 | open fun filterModel(obj: JSONObject): Boolean = true |
| 101 | } |
| 102 | |
| 103 | // ── Concrete providers ──────────────────────────────────────────────────────── |
| 104 | |
| 105 | object OpenAiProvider : OpenAiCompatibleProvider() { |
| 106 | override val id = "openai" |
| 107 | override val displayName = "OpenAI" |
| 108 | override val defaultBaseUrl = "https://api.openai.com" |
| 109 | override val defaultModel = "gpt-4o" |
| 110 | override val requiresApiKey = true |
| 111 | |
| 112 | override fun filterModel(obj: JSONObject): Boolean { |
| 113 | val id = obj.optString("id", "") |
| 114 | return id.startsWith("gpt-") || id.startsWith("o1") || id.startsWith("o3") || id.startsWith("o4") |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | object GroqProvider : OpenAiCompatibleProvider() { |
| 119 | override val id = "groq" |
| 120 | override val displayName = "Groq" |
| 121 | override val defaultBaseUrl = "https://api.groq.com/openai" |
| 122 | override val defaultModel = "llama-3.3-70b-versatile" |
| 123 | override val requiresApiKey = true |
| 124 | } |
| 125 | |
| 126 | object MistralProvider : OpenAiCompatibleProvider() { |
| 127 | override val id = "mistral" |
| 128 | override val displayName = "Mistral" |
| 129 | override val defaultBaseUrl = "https://api.mistral.ai" |
| 130 | override val defaultModel = "mistral-large-latest" |
| 131 | override val requiresApiKey = true |
| 132 | } |
| 133 | |
| 134 | object OllamaProvider : OpenAiCompatibleProvider() { |
| 135 | override val id = "ollama" |
| 136 | override val displayName = "Ollama (local)" |
| 137 | override val defaultBaseUrl = "http://localhost:11434" |
| 138 | override val defaultModel = "llama3" |
| 139 | override val requiresApiKey = false |
| 140 | override val allowsBaseUrlOverride = true |
| 141 | } |
| 142 | |
| 143 | object CustomOpenAiProvider : OpenAiCompatibleProvider() { |
| 144 | override val id = "custom" |
| 145 | override val displayName = "Custom (OpenAI-compatible)" |
| 146 | override val defaultBaseUrl = "" |
| 147 | override val defaultModel = "" |
| 148 | override val requiresApiKey = false |
| 149 | override val allowsBaseUrlOverride = true |
| 150 | } |