ai-chat-android-auto / files / app / src / main / java / net / fuzzyworld / aichat / OpenAiCompatibleProvider.kt
OpenAiCompatibleProvider.kt
d522fe6d 2026-05-15 Pavel Safronov | 1 | package net.fuzzyworld.aichat |
449ce939 2026-05-14 Pavel Safronov | 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 | } |