fuzzyworld

ai-chat-android-auto / commit

Add multi-provider support with user-selectable models

449ce9390c06f65e54cf8e263507a60e0bf4d295 · Pavel Safronov · 2026-05-14 16:43:52 -0700 · parents 1059e57a8d12

Add multi-provider support with user-selectable models

Replaces the hard-coded ClaudeApiClient with an extensible provider system:

AiProvider interface — ask() + listModels() contract that every provider
implements. ModelInfo(id, displayName) carries model data.

ProviderRegistry — static list of all providers. Adding an OpenAI-compatible
provider is ~5 lines (extend OpenAiCompatibleProvider, override 5 vals, add
to the registry list). Unique API formats implement AiProvider directly.

Providers included:
  - AnthropicProvider  (Messages API, built-in web search)
  - OpenAiProvider     (gpt-4o)
  - GroqProvider       (llama-3.3-70b-versatile)
  - MistralProvider    (mistral-large-latest)
  - GeminiProvider     (gemini-2.0-flash, generateContent API)
  - OllamaProvider     (local, no key, configurable base URL)
  - CustomOpenAiProvider (any /v1/chat/completions endpoint)

Prefs — per-provider storage for API key, base URL override, selected model,
and cached model list. All keyed by provider id (e.g. "key_anthropic").
isReady() gives the media service a single readiness check.

SetupActivity — redesigned with provider spinner, conditional API key /
base URL groups, model spinner with ↻ fetch button. Model list is fetched
from the provider's API and cached; auto-fetches when credentials are ready.

ClaudeMediaService — uses ProviderRegistry.byId(Prefs.selectedProviderId())
to dispatch calls; UI strings de-branded to "Ask AI" / "AI Auto".

README added with setup instructions, provider table, contribution guide
for adding new providers, and upcoming work list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
 README.md                                          | 104 +++++++++++
 .../java/net/fuzzyworld/claudeauto/AiProvider.kt   |  39 ++++
 .../net/fuzzyworld/claudeauto/AnthropicProvider.kt | 107 +++++++++++
 .../net/fuzzyworld/claudeauto/ClaudeApiClient.kt   |  75 --------
 .../fuzzyworld/claudeauto/ClaudeMediaService.kt    |  30 ++--
 .../net/fuzzyworld/claudeauto/GeminiProvider.kt    | 109 +++++++++++
 .../claudeauto/OpenAiCompatibleProvider.kt         | 150 ++++++++++++++++
 .../main/java/net/fuzzyworld/claudeauto/Prefs.kt   |  88 ++++++++-
 .../net/fuzzyworld/claudeauto/ProviderRegistry.kt  |  21 +++
 .../net/fuzzyworld/claudeauto/SetupActivity.kt     | 200 +++++++++++++++++++--
 app/src/main/res/layout/activity_setup.xml         | 185 ++++++++++++++-----
 11 files changed, 955 insertions(+), 153 deletions(-)

diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d1a8e27
--- /dev/null
+++ b/README.md
@@ -0,0 +1,104 @@
+# AI Auto
+
+An Android Auto voice assistant that sends your spoken questions to an AI provider and reads the responses back through the car's speakers.
+
+You choose the provider — Anthropic, OpenAI, Groq, Mistral, Google Gemini, or a local Ollama instance. API keys are stored on-device in `EncryptedSharedPreferences` and never leave the phone.
+
+---
+
+## How it works
+
+1. Open the **AI Auto** app on your phone and configure a provider + API key.
+2. Connect your phone to the car. The app appears in the Android Auto launcher.
+3. Tap **Ask AI** (or press Play on the steering wheel) and speak your question.
+4. The app sends your question to the provider's API and reads the answer aloud.
+5. Past exchanges appear in the browse list so you can re-ask any previous question.
+
+---
+
+## Setup
+
+### Requirements
+- Android 6.0+ (API 23)
+- Android Auto on the phone
+
+### Phone setup
+1. Install the APK (sideload or Play Store release).
+2. Open **AI Auto** on your phone.
+3. Select a provider, enter your API key, and tap **Save Key**.
+4. Tap **↻** to fetch available models and select one.
+5. Grant microphone permission when prompted.
+
+### Android Auto
+The app registers as a media browser service, so it requires no special permissions beyond a standard media app. If using a sideloaded build, enable **Unknown sources** for Android Auto via ADB first:
+
+```
+adb shell settings put global android_auto_unknown_sources 1
+```
+
+---
+
+## Supported providers
+
+| Provider | API key required | Web search | Notes |
+|---|---|---|---|
+| [Anthropic](https://console.anthropic.com) | ✅ | ✅ (built-in) | Default provider |
+| [OpenAI](https://platform.openai.com) | ✅ | — | GPT-4o and newer |
+| [Groq](https://console.groq.com) | ✅ | — | Very fast inference |
+| [Mistral](https://console.mistral.ai) | ✅ | — | |
+| [Google Gemini](https://aistudio.google.com) | ✅ | — | |
+| Ollama (local) | — | — | Requires local server; configure base URL |
+| Custom (OpenAI-compatible) | Optional | — | Any endpoint that speaks `/v1/chat/completions` |
+
+---
+
+## Adding a new provider
+
+### OpenAI-compatible API (5 lines)
+
+Add an object to [OpenAiCompatibleProvider.kt](app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt):
+
+```kotlin
+object MyProvider : OpenAiCompatibleProvider() {
+    override val id           = "myprovider"       // stable, never change
+    override val displayName  = "My Provider"
+    override val defaultBaseUrl = "https://api.myprovider.com"
+    override val defaultModel = "my-model-name"
+    override val requiresApiKey = true
+}
+```
+
+Then add `MyProvider` to the list in [ProviderRegistry.kt](app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt).
+
+### Unique API format
+
+Implement the `AiProvider` interface directly (see [GeminiProvider.kt](app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt) as an example), then register it in `ProviderRegistry`.
+
+The interface requires two methods:
+- `ask(apiKey, baseUrl, model, question, onResult)` — call the API and return the text response
+- `listModels(apiKey, baseUrl, onResult)` — return a list of `ModelInfo(id, displayName)`
+
+---
+
+## Building from source
+
+```bash
+# Clone and open in Android Studio, or build from the command line:
+./gradlew assembleRelease
+```
+
+Signing credentials go in `local.properties` (gitignored). See `local.properties.example`.
+
+---
+
+## Upcoming work
+
+- **App rename / rebrand** — the app is currently labelled "Claude Auto" in places; needs a provider-neutral name for the open-source release
+- **Persist conversation history across sessions** — current history is in-memory only and lost when the service stops
+- **Per-provider system prompt customization** — let users adjust the assistant's persona and instructions
+- **Streaming response support** — start speaking the first sentence while the rest of the response is still arriving, reducing perceived latency
+- **Web search for non-Anthropic providers** — integrate an external search API (e.g. Brave Search, Tavily) so all providers can answer current-events questions
+- **Gemini grounding / web search** — wire up Google's native grounding tool for Gemini responses
+- **Hands-free activation** — start listening without tapping, triggered by a wake phrase or steering-wheel button mapping
+- **Export / import configuration** — back up and restore provider settings and API keys
+- **Android Automotive OS (AAOS) support** — native in-car OS support alongside the current Android Auto projection mode
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/AiProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/AiProvider.kt
new file mode 100644
index 0000000..33befd8
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/AiProvider.kt
@@ -0,0 +1,39 @@
+package net.fuzzyworld.claudeauto
+
+import okhttp3.OkHttpClient
+import java.util.concurrent.TimeUnit
+
+data class ModelInfo(val id: String, val displayName: String)
+
+interface AiProvider {
+    /** Stable key used in SharedPreferences. Never change after release. */
+    val id: String
+    val displayName: String
+    val defaultBaseUrl: String
+    val defaultModel: String
+    val requiresApiKey: Boolean
+    /** Whether the user can override the base URL (Ollama, Custom). */
+    val allowsBaseUrlOverride: Boolean
+    val supportsWebSearch: Boolean
+
+    fun ask(
+        apiKey: String,
+        baseUrl: String,
+        model: String,
+        question: String,
+        onResult: (String?) -> Unit,
+    )
+
+    fun listModels(
+        apiKey: String,
+        baseUrl: String,
+        onResult: (List<ModelInfo>?) -> Unit,
+    )
+}
+
+internal object Http {
+    val client: OkHttpClient = OkHttpClient.Builder()
+        .connectTimeout(30, TimeUnit.SECONDS)
+        .readTimeout(90, TimeUnit.SECONDS)
+        .build()
+}
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/AnthropicProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/AnthropicProvider.kt
new file mode 100644
index 0000000..9ce2be2
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/AnthropicProvider.kt
@@ -0,0 +1,107 @@
+package net.fuzzyworld.claudeauto
+
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+
+object AnthropicProvider : AiProvider {
+    override val id = "anthropic"
+    override val displayName = "Anthropic"
+    override val defaultBaseUrl = "https://api.anthropic.com"
+    override val defaultModel = "claude-sonnet-4-6"
+    override val requiresApiKey = true
+    override val allowsBaseUrlOverride = false
+    override val supportsWebSearch = true
+
+    override fun ask(
+        apiKey: String,
+        baseUrl: String,
+        model: String,
+        question: String,
+        onResult: (String?) -> Unit,
+    ) {
+        val payload = JSONObject().apply {
+            put("model", model)
+            put("max_tokens", 1024)
+            put("system", SYSTEM_PROMPT)
+            put("messages", JSONArray().apply {
+                put(JSONObject().apply {
+                    put("role", "user")
+                    put("content", question)
+                })
+            })
+            put("tools", JSONArray().apply {
+                put(JSONObject().apply {
+                    put("type", "web_search_20250305")
+                    put("name", "web_search")
+                })
+            })
+        }.toString().toRequestBody("application/json".toMediaType())
+
+        val request = Request.Builder()
+            .url("$baseUrl/v1/messages")
+            .post(payload)
+            .addHeader("x-api-key", apiKey)
+            .addHeader("anthropic-version", "2023-06-01")
+            .addHeader("anthropic-beta", "web-search-2025-03-05")
+            .build()
+
+        Http.client.newCall(request).enqueue(object : Callback {
+            override fun onFailure(call: Call, e: IOException) = onResult(null)
+            override fun onResponse(call: Call, response: Response) {
+                response.use {
+                    if (!it.isSuccessful) { onResult(null); return }
+                    try {
+                        val content = JSONObject(it.body!!.string()).getJSONArray("content")
+                        val text = buildString {
+                            for (i in 0 until content.length()) {
+                                val block = content.getJSONObject(i)
+                                if (block.getString("type") == "text") append(block.getString("text"))
+                            }
+                        }.trim()
+                        onResult(text.ifEmpty { null })
+                    } catch (_: Exception) { onResult(null) }
+                }
+            }
+        })
+    }
+
+    override fun listModels(
+        apiKey: String,
+        baseUrl: String,
+        onResult: (List<ModelInfo>?) -> Unit,
+    ) {
+        val request = Request.Builder()
+            .url("$baseUrl/v1/models")
+            .get()
+            .addHeader("x-api-key", apiKey)
+            .addHeader("anthropic-version", "2023-06-01")
+            .build()
+
+        Http.client.newCall(request).enqueue(object : Callback {
+            override fun onFailure(call: Call, e: IOException) = onResult(null)
+            override fun onResponse(call: Call, response: Response) {
+                response.use {
+                    if (!it.isSuccessful) { onResult(null); return }
+                    try {
+                        val data = JSONObject(it.body!!.string()).getJSONArray("data")
+                        val models = (0 until data.length()).map { i ->
+                            val obj = data.getJSONObject(i)
+                            ModelInfo(
+                                id = obj.getString("id"),
+                                displayName = obj.optString("display_name", obj.getString("id")),
+                            )
+                        }
+                        onResult(models)
+                    } catch (_: Exception) { onResult(null) }
+                }
+            }
+        })
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt
deleted file mode 100644
index 22d3719..0000000
--- a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeApiClient.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-package net.fuzzyworld.claudeauto
-
-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 ClaudeApiClient {
-
-    private val http = OkHttpClient.Builder()
-        .connectTimeout(30, TimeUnit.SECONDS)
-        .readTimeout(90, TimeUnit.SECONDS)
-        .build()
-
-    fun ask(apiKey: String, question: String, onResult: (String?) -> Unit) {
-        val payload = JSONObject().apply {
-            put("model", "claude-sonnet-4-6")
-            put("max_tokens", 1024)
-            put(
-                "system",
-                "You are a voice assistant in a car. Be concise and conversational — " +
-                    "keep responses to 1-3 sentences unless genuinely more is needed. " +
-                    "Never use markdown, bullet points, code blocks, or lists. " +
-                    "Speak naturally, as if talking to a driver."
-            )
-            put("messages", JSONArray().apply {
-                put(JSONObject().apply {
-                    put("role", "user")
-                    put("content", question)
-                })
-            })
-            put("tools", JSONArray().apply {
-                put(JSONObject().apply {
-                    put("type", "web_search_20250305")
-                    put("name", "web_search")
-                })
-            })
-        }.toString().toRequestBody("application/json".toMediaType())
-
-        val request = Request.Builder()
-            .url("https://api.anthropic.com/v1/messages")
-            .post(payload)
-            .addHeader("x-api-key", apiKey)
-            .addHeader("anthropic-version", "2023-06-01")
-            .addHeader("anthropic-beta", "web-search-2025-03-05")
-            .build()
-
-        http.newCall(request).enqueue(object : Callback {
-            override fun onFailure(call: Call, e: IOException) = onResult(null)
-
-            override fun onResponse(call: Call, response: Response) {
-                response.use {
-                    if (!it.isSuccessful) { onResult(null); return }
-                    try {
-                        val content = JSONObject(it.body!!.string()).getJSONArray("content")
-                        val text = buildString {
-                            for (i in 0 until content.length()) {
-                                val block = content.getJSONObject(i)
-                                if (block.getString("type") == "text") {
-                                    append(block.getString("text"))
-                                }
-                            }
-                        }.trim()
-                        onResult(text.ifEmpty { null })
-                    } catch (_: Exception) {
-                        onResult(null)
-                    }
-                }
-            }
-        })
-    }
-}
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt
index 155e394..12d82dc 100644
--- a/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/ClaudeMediaService.kt
@@ -38,7 +38,6 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
     private var focusRequest: AudioFocusRequest? = null
 
     private val mainHandler = Handler(Looper.getMainLooper())
-    private val api = ClaudeApiClient()
 
     companion object {
         private const val ROOT_ID          = "__ROOT__"
@@ -101,7 +100,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
         items += MediaItem(
             MediaDescriptionCompat.Builder()
                 .setMediaId(ASK_ID)
-                .setTitle("Ask Claude")
+                .setTitle("Ask AI")
                 .setSubtitle("Tap ▶ to speak")
                 .build(),
             MediaItem.FLAG_PLAYABLE
@@ -144,7 +143,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
                 id == CLEAR_ID -> clearHistory()
                 id?.startsWith(HISTORY_PREFIX) == true -> {
                     val idx = id.removePrefix(HISTORY_PREFIX).toIntOrNull()
-                    if (idx != null && idx in history.indices) sendToClaude(history[idx].question)
+                    if (idx != null && idx in history.indices) sendToProvider(history[idx].question)
                 }
             }
         }
@@ -201,8 +200,8 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
     // ── Voice input ────────────────────────────────────────────────────────────
 
     private fun startListening() {
-        if (!Prefs.hasApiKey(this)) {
-            speak("No API key configured. Open the Claude Auto app on your phone to add it.")
+        if (!Prefs.isReady(this)) {
+            speak("Please finish setting up the app on your phone before using it.")
             return
         }
         if (!SpeechRecognizer.isRecognitionAvailable(this)) {
@@ -227,7 +226,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
     private val recognitionListener = object : RecognitionListener {
         override fun onResults(r: Bundle?) {
             val text = r?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()
-            if (text != null) sendToClaude(text)
+            if (text != null) sendToProvider(text)
             else mainHandler.post { releaseAudioFocus(); speak("Couldn't understand — please try again.") }
         }
         override fun onError(error: Int) {
@@ -253,11 +252,18 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
 
     // ── API + TTS ──────────────────────────────────────────────────────────────
 
-    private fun sendToClaude(question: String) {
+    private fun sendToProvider(question: String) {
         pushState(State.PROCESSING, question)
-        api.ask(Prefs.apiKey(this), question) { response ->
+        val providerId = Prefs.selectedProviderId(this)
+        val provider   = ProviderRegistry.byId(providerId)
+        provider.ask(
+            apiKey   = Prefs.apiKey(this, providerId),
+            baseUrl  = Prefs.baseUrl(this, providerId),
+            model    = Prefs.model(this, providerId),
+            question = question,
+        ) { response ->
             mainHandler.post {
-                val answer = response ?: "Couldn't reach Claude. Check your internet connection."
+                val answer = response ?: "Couldn't reach the AI. Check your internet connection."
                 history.add(HistoryEntry(question, answer))
                 notifyChildrenChanged(ROOT_ID)
                 speak(answer)
@@ -277,11 +283,11 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
         state = newState
 
         val (title, subtitle) = when (newState) {
-            State.IDLE       -> "Ask Claude" to
+            State.IDLE       -> "Ask AI" to
                                 (history.lastOrNull()?.answer ?: "Tap ▶ to speak")
             State.LISTENING  -> "Listening…"  to "Speak your question"
             State.PROCESSING -> "Thinking…"   to (if (text.isNotEmpty()) "\"$text\"" else "")
-            State.SPEAKING   -> "Claude"       to text
+            State.SPEAKING   -> "AI"            to text
         }
 
         val pbState = when (newState) {
@@ -301,7 +307,7 @@ class ClaudeMediaService : MediaBrowserServiceCompat(), TextToSpeech.OnInitListe
             MediaMetadataCompat.Builder()
                 .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
                 .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, subtitle)
-                .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "Claude Auto")
+                .putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "AI Auto")
                 .putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, subtitle)
                 .build()
         )
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt
new file mode 100644
index 0000000..b33c0a1
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/GeminiProvider.kt
@@ -0,0 +1,109 @@
+package net.fuzzyworld.claudeauto
+
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+
+object GeminiProvider : AiProvider {
+    override val id = "gemini"
+    override val displayName = "Google Gemini"
+    override val defaultBaseUrl = "https://generativelanguage.googleapis.com"
+    override val defaultModel = "gemini-2.0-flash"
+    override val requiresApiKey = true
+    override val allowsBaseUrlOverride = false
+    override val supportsWebSearch = false
+
+    override fun ask(
+        apiKey: String,
+        baseUrl: String,
+        model: String,
+        question: String,
+        onResult: (String?) -> Unit,
+    ) {
+        val payload = JSONObject().apply {
+            put("system_instruction", JSONObject().apply {
+                put("parts", JSONArray().apply {
+                    put(JSONObject().apply { put("text", SYSTEM_PROMPT) })
+                })
+            })
+            put("contents", JSONArray().apply {
+                put(JSONObject().apply {
+                    put("role", "user")
+                    put("parts", JSONArray().apply {
+                        put(JSONObject().apply { put("text", question) })
+                    })
+                })
+            })
+        }.toString().toRequestBody("application/json".toMediaType())
+
+        val request = Request.Builder()
+            .url("$baseUrl/v1beta/models/$model:generateContent?key=$apiKey")
+            .post(payload)
+            .build()
+
+        Http.client.newCall(request).enqueue(object : Callback {
+            override fun onFailure(call: Call, e: IOException) = onResult(null)
+            override fun onResponse(call: Call, response: Response) {
+                response.use {
+                    if (!it.isSuccessful) { onResult(null); return }
+                    try {
+                        val text = JSONObject(it.body!!.string())
+                            .getJSONArray("candidates")
+                            .getJSONObject(0)
+                            .getJSONObject("content")
+                            .getJSONArray("parts")
+                            .getJSONObject(0)
+                            .getString("text")
+                        onResult(text.trim().ifEmpty { null })
+                    } catch (_: Exception) { onResult(null) }
+                }
+            }
+        })
+    }
+
+    override fun listModels(
+        apiKey: String,
+        baseUrl: String,
+        onResult: (List<ModelInfo>?) -> Unit,
+    ) {
+        val request = Request.Builder()
+            .url("$baseUrl/v1beta/models?key=$apiKey")
+            .get()
+            .build()
+
+        Http.client.newCall(request).enqueue(object : Callback {
+            override fun onFailure(call: Call, e: IOException) = onResult(null)
+            override fun onResponse(call: Call, response: Response) {
+                response.use {
+                    if (!it.isSuccessful) { onResult(null); return }
+                    try {
+                        val arr = JSONObject(it.body!!.string()).getJSONArray("models")
+                        val models = (0 until arr.length())
+                            .map { i -> arr.getJSONObject(i) }
+                            .filter { obj ->
+                                val methods = obj.optJSONArray("supportedGenerationMethods")
+                                methods != null && (0 until methods.length()).any { j ->
+                                    methods.getString(j) == "generateContent"
+                                }
+                            }
+                            .map { obj ->
+                                // "name" is "models/gemini-2.0-flash" — strip the prefix
+                                val mid = obj.getString("name").removePrefix("models/")
+                                ModelInfo(
+                                    id = mid,
+                                    displayName = obj.optString("displayName", mid),
+                                )
+                            }
+                        onResult(models)
+                    } catch (_: Exception) { onResult(null) }
+                }
+            }
+        })
+    }
+}
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt b/app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt
new file mode 100644
index 0000000..2ee0eb5
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/OpenAiCompatibleProvider.kt
@@ -0,0 +1,150 @@
+package net.fuzzyworld.claudeauto
+
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import okhttp3.Response
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.IOException
+
+abstract class OpenAiCompatibleProvider : AiProvider {
+    override val supportsWebSearch = false
+    override val allowsBaseUrlOverride = false
+
+    override fun ask(
+        apiKey: String,
+        baseUrl: String,
+        model: String,
+        question: String,
+        onResult: (String?) -> Unit,
+    ) {
+        val payload = JSONObject().apply {
+            put("model", model)
+            put("max_tokens", 1024)
+            put("messages", JSONArray().apply {
+                put(JSONObject().apply {
+                    put("role", "system")
+                    put("content", SYSTEM_PROMPT)
+                })
+                put(JSONObject().apply {
+                    put("role", "user")
+                    put("content", question)
+                })
+            })
+        }.toString().toRequestBody("application/json".toMediaType())
+
+        val request = Request.Builder()
+            .url("$baseUrl/v1/chat/completions")
+            .post(payload)
+            .addHeader("Authorization", "Bearer $apiKey")
+            .build()
+
+        Http.client.newCall(request).enqueue(object : Callback {
+            override fun onFailure(call: Call, e: IOException) = onResult(null)
+            override fun onResponse(call: Call, response: Response) {
+                response.use {
+                    if (!it.isSuccessful) { onResult(null); return }
+                    try {
+                        val text = JSONObject(it.body!!.string())
+                            .getJSONArray("choices")
+                            .getJSONObject(0)
+                            .getJSONObject("message")
+                            .getString("content")
+                        onResult(text.trim().ifEmpty { null })
+                    } catch (_: Exception) { onResult(null) }
+                }
+            }
+        })
+    }
+
+    override fun listModels(
+        apiKey: String,
+        baseUrl: String,
+        onResult: (List<ModelInfo>?) -> Unit,
+    ) {
+        val request = Request.Builder()
+            .url("$baseUrl/v1/models")
+            .get()
+            .addHeader("Authorization", "Bearer $apiKey")
+            .build()
+
+        Http.client.newCall(request).enqueue(object : Callback {
+            override fun onFailure(call: Call, e: IOException) = onResult(null)
+            override fun onResponse(call: Call, response: Response) {
+                response.use {
+                    if (!it.isSuccessful) { onResult(null); return }
+                    try {
+                        val data = JSONObject(it.body!!.string()).getJSONArray("data")
+                        val models = (0 until data.length())
+                            .map { i -> data.getJSONObject(i) }
+                            .filter { obj -> filterModel(obj) }
+                            .map { obj ->
+                                val mid = obj.getString("id")
+                                ModelInfo(
+                                    id = mid,
+                                    displayName = obj.optString("name", mid),
+                                )
+                            }
+                            .sortedBy { it.id }
+                        onResult(models)
+                    } catch (_: Exception) { onResult(null) }
+                }
+            }
+        })
+    }
+
+    /** Override to restrict which models appear in the list. */
+    open fun filterModel(obj: JSONObject): Boolean = true
+}
+
+// ── Concrete providers ────────────────────────────────────────────────────────
+
+object OpenAiProvider : OpenAiCompatibleProvider() {
+    override val id = "openai"
+    override val displayName = "OpenAI"
+    override val defaultBaseUrl = "https://api.openai.com"
+    override val defaultModel = "gpt-4o"
+    override val requiresApiKey = true
+
+    override fun filterModel(obj: JSONObject): Boolean {
+        val id = obj.optString("id", "")
+        return id.startsWith("gpt-") || id.startsWith("o1") || id.startsWith("o3") || id.startsWith("o4")
+    }
+}
+
+object GroqProvider : OpenAiCompatibleProvider() {
+    override val id = "groq"
+    override val displayName = "Groq"
+    override val defaultBaseUrl = "https://api.groq.com/openai"
+    override val defaultModel = "llama-3.3-70b-versatile"
+    override val requiresApiKey = true
+}
+
+object MistralProvider : OpenAiCompatibleProvider() {
+    override val id = "mistral"
+    override val displayName = "Mistral"
+    override val defaultBaseUrl = "https://api.mistral.ai"
+    override val defaultModel = "mistral-large-latest"
+    override val requiresApiKey = true
+}
+
+object OllamaProvider : OpenAiCompatibleProvider() {
+    override val id = "ollama"
+    override val displayName = "Ollama (local)"
+    override val defaultBaseUrl = "http://localhost:11434"
+    override val defaultModel = "llama3"
+    override val requiresApiKey = false
+    override val allowsBaseUrlOverride = true
+}
+
+object CustomOpenAiProvider : OpenAiCompatibleProvider() {
+    override val id = "custom"
+    override val displayName = "Custom (OpenAI-compatible)"
+    override val defaultBaseUrl = ""
+    override val defaultModel = ""
+    override val requiresApiKey = false
+    override val allowsBaseUrlOverride = true
+}
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt b/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt
index ab34d61..8b9fc09 100644
--- a/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/Prefs.kt
@@ -4,10 +4,12 @@ import android.content.Context
 import android.content.SharedPreferences
 import androidx.security.crypto.EncryptedSharedPreferences
 import androidx.security.crypto.MasterKey
+import org.json.JSONArray
+import org.json.JSONObject
 
 object Prefs {
-    private const val FILE = "claude_prefs"
-    private const val KEY = "api_key"
+    private const val FILE = "ai_auto_prefs"
+    private const val KEY_PROVIDER = "provider_id"
 
     private fun prefs(context: Context): SharedPreferences {
         val master = MasterKey.Builder(context)
@@ -16,12 +18,84 @@ object Prefs {
         return EncryptedSharedPreferences.create(
             context, FILE, master,
             EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
-            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+            EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
         )
     }
 
-    fun apiKey(context: Context): String = prefs(context).getString(KEY, "") ?: ""
-    fun hasApiKey(context: Context): Boolean = apiKey(context).isNotBlank()
-    fun saveApiKey(context: Context, key: String) = prefs(context).edit().putString(KEY, key).apply()
-    fun clearApiKey(context: Context) = prefs(context).edit().remove(KEY).apply()
+    // ── Provider selection ─────────────────────────────────────────────────────
+
+    fun selectedProviderId(context: Context): String =
+        prefs(context).getString(KEY_PROVIDER, AnthropicProvider.id) ?: AnthropicProvider.id
+
+    fun saveProviderId(context: Context, id: String) =
+        prefs(context).edit().putString(KEY_PROVIDER, id).apply()
+
+    // ── Per-provider API key ───────────────────────────────────────────────────
+
+    fun apiKey(context: Context, providerId: String = selectedProviderId(context)): String =
+        prefs(context).getString("key_$providerId", "") ?: ""
+
+    fun hasApiKey(context: Context, providerId: String = selectedProviderId(context)): Boolean =
+        apiKey(context, providerId).isNotBlank()
+
+    fun saveApiKey(context: Context, providerId: String, key: String) =
+        prefs(context).edit().putString("key_$providerId", key).apply()
+
+    fun clearApiKey(context: Context, providerId: String) =
+        prefs(context).edit().remove("key_$providerId").apply()
+
+    // ── Per-provider base URL (with fallback to provider default) ──────────────
+
+    fun baseUrl(context: Context, providerId: String = selectedProviderId(context)): String {
+        val default = ProviderRegistry.byId(providerId).defaultBaseUrl
+        return prefs(context).getString("url_$providerId", default)?.ifBlank { default } ?: default
+    }
+
+    fun saveBaseUrl(context: Context, providerId: String, url: String) =
+        prefs(context).edit().putString("url_$providerId", url).apply()
+
+    // ── Per-provider selected model (with fallback to provider default) ─────────
+
+    fun model(context: Context, providerId: String = selectedProviderId(context)): String {
+        val default = ProviderRegistry.byId(providerId).defaultModel
+        return prefs(context).getString("model_$providerId", default)?.ifBlank { default } ?: default
+    }
+
+    fun saveModel(context: Context, providerId: String, modelId: String) =
+        prefs(context).edit().putString("model_$providerId", modelId).apply()
+
+    // ── Cached model list ──────────────────────────────────────────────────────
+
+    fun cachedModels(context: Context, providerId: String): List<ModelInfo>? {
+        val json = prefs(context).getString("models_$providerId", null) ?: return null
+        return try {
+            val arr = JSONArray(json)
+            (0 until arr.length()).map { i ->
+                val obj = arr.getJSONObject(i)
+                ModelInfo(obj.getString("id"), obj.getString("displayName"))
+            }
+        } catch (_: Exception) { null }
+    }
+
+    fun saveModelCache(context: Context, providerId: String, models: List<ModelInfo>) {
+        val arr = JSONArray().apply {
+            models.forEach { m ->
+                put(JSONObject().apply {
+                    put("id", m.id)
+                    put("displayName", m.displayName)
+                })
+            }
+        }
+        prefs(context).edit().putString("models_$providerId", arr.toString()).apply()
+    }
+
+    // ── Ready check (used by the media service) ────────────────────────────────
+
+    fun isReady(context: Context): Boolean {
+        val providerId = selectedProviderId(context)
+        val provider = ProviderRegistry.byId(providerId)
+        if (provider.requiresApiKey && !hasApiKey(context, providerId)) return false
+        if (provider.allowsBaseUrlOverride && baseUrl(context, providerId).isBlank()) return false
+        return model(context, providerId).isNotBlank()
+    }
 }
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt b/app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt
new file mode 100644
index 0000000..25cb31e
--- /dev/null
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/ProviderRegistry.kt
@@ -0,0 +1,21 @@
+package net.fuzzyworld.claudeauto
+
+internal const val SYSTEM_PROMPT =
+    "You are a voice assistant in a car. Be concise and conversational — " +
+    "keep responses to 1–3 sentences unless genuinely more detail is needed. " +
+    "Never use markdown, bullet points, code blocks, or lists. " +
+    "Speak naturally, as if talking to a driver."
+
+object ProviderRegistry {
+    val all: List<AiProvider> = listOf(
+        AnthropicProvider,
+        OpenAiProvider,
+        GroqProvider,
+        MistralProvider,
+        GeminiProvider,
+        OllamaProvider,
+        CustomOpenAiProvider,
+    )
+
+    fun byId(id: String): AiProvider = all.find { it.id == id } ?: AnthropicProvider
+}
diff --git a/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt b/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt
index 145109d..eadcc8b 100644
--- a/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt
+++ b/app/src/main/java/net/fuzzyworld/claudeauto/SetupActivity.kt
@@ -3,10 +3,15 @@ package net.fuzzyworld.claudeauto
 import android.Manifest
 import android.content.pm.PackageManager
 import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
 import android.view.View
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
 import android.widget.Button
 import android.widget.EditText
 import android.widget.LinearLayout
+import android.widget.Spinner
 import android.widget.TextView
 import android.widget.Toast
 import androidx.activity.result.contract.ActivityResultContracts
@@ -15,59 +20,220 @@ import androidx.core.content.ContextCompat
 
 class SetupActivity : AppCompatActivity() {
 
-    private lateinit var enterKeyGroup: LinearLayout
+    private lateinit var providerSpinner: Spinner
+    private lateinit var apiKeyGroup: LinearLayout
     private lateinit var keyInput: EditText
-    private lateinit var clearBtn: Button
+    private lateinit var saveKeyBtn: Button
+    private lateinit var clearKeyBtn: Button
+    private lateinit var baseUrlGroup: LinearLayout
+    private lateinit var baseUrlInput: EditText
+    private lateinit var modelSpinner: Spinner
     private lateinit var statusView: TextView
 
+    private var selectedProvider: AiProvider = AnthropicProvider
+    private var modelList: List<ModelInfo> = emptyList()
+    private var initialized = false
+
+    private val mainHandler = Handler(Looper.getMainLooper())
+
     private val requestMic = registerForActivityResult(
         ActivityResultContracts.RequestPermission()
-    ) { _ -> refreshUI() }
+    ) { _ -> updateStatus() }
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_setup)
 
-        enterKeyGroup = findViewById(R.id.enterKeyGroup)
-        keyInput = findViewById(R.id.keyInput)
-        clearBtn = findViewById(R.id.clearBtn)
-        statusView = findViewById(R.id.status)
+        providerSpinner = findViewById(R.id.providerSpinner)
+        apiKeyGroup     = findViewById(R.id.apiKeyGroup)
+        keyInput        = findViewById(R.id.keyInput)
+        saveKeyBtn      = findViewById(R.id.saveKeyBtn)
+        clearKeyBtn     = findViewById(R.id.clearKeyBtn)
+        baseUrlGroup    = findViewById(R.id.baseUrlGroup)
+        baseUrlInput    = findViewById(R.id.baseUrlInput)
+        modelSpinner    = findViewById(R.id.modelSpinner)
+        statusView      = findViewById(R.id.status)
+
+        setupProviderSpinner()
+        setupModelSpinner()
 
-        findViewById<Button>(R.id.saveBtn).setOnClickListener {
+        saveKeyBtn.setOnClickListener {
             val key = keyInput.text.toString().trim()
             if (key.isBlank()) {
                 Toast.makeText(this, "Paste your API key first.", Toast.LENGTH_SHORT).show()
                 return@setOnClickListener
             }
-            Prefs.saveApiKey(this, key)
+            Prefs.saveApiKey(this, selectedProvider.id, key)
             keyInput.setText("")
             Toast.makeText(this, "API key saved.", Toast.LENGTH_SHORT).show()
             refreshUI()
+            maybeAutoFetch()
         }
 
-        clearBtn.setOnClickListener {
-            Prefs.clearApiKey(this)
+        clearKeyBtn.setOnClickListener {
+            Prefs.clearApiKey(this, selectedProvider.id)
             Toast.makeText(this, "API key cleared.", Toast.LENGTH_SHORT).show()
             refreshUI()
         }
 
+        findViewById<Button>(R.id.saveUrlBtn).setOnClickListener {
+            val url = baseUrlInput.text.toString().trim()
+            if (url.isBlank()) {
+                Toast.makeText(this, "Enter a base URL first.", Toast.LENGTH_SHORT).show()
+                return@setOnClickListener
+            }
+            Prefs.saveBaseUrl(this, selectedProvider.id, url)
+            Toast.makeText(this, "URL saved.", Toast.LENGTH_SHORT).show()
+            refreshUI()
+            maybeAutoFetch()
+        }
+
+        findViewById<Button>(R.id.fetchModelsBtn).setOnClickListener { fetchModels() }
+
         if (!hasMic()) requestMic.launch(Manifest.permission.RECORD_AUDIO)
+
+        // defer so all setSelection() calls during init fire before listeners start saving
+        mainHandler.post { initialized = true }
+    }
+
+    // ── Provider spinner ───────────────────────────────────────────────────────
+
+    private fun setupProviderSpinner() {
+        val names = ProviderRegistry.all.map { it.displayName }
+        providerSpinner.adapter = ArrayAdapter(
+            this, android.R.layout.simple_spinner_item, names
+        ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
+
+        val savedId = Prefs.selectedProviderId(this)
+        val savedIdx = ProviderRegistry.all.indexOfFirst { it.id == savedId }.coerceAtLeast(0)
+        selectedProvider = ProviderRegistry.all[savedIdx]
+        providerSpinner.setSelection(savedIdx)
+
+        providerSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+            override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) {
+                if (!initialized) return
+                selectedProvider = ProviderRegistry.all[pos]
+                Prefs.saveProviderId(this@SetupActivity, selectedProvider.id)
+                refreshUI()
+                maybeAutoFetch()
+            }
+            override fun onNothingSelected(parent: AdapterView<*>?) {}
+        }
+
         refreshUI()
     }
 
+    // ── Model spinner ──────────────────────────────────────────────────────────
+
+    private fun setupModelSpinner() {
+        modelSpinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+            override fun onItemSelected(parent: AdapterView<*>?, view: View?, pos: Int, id: Long) {
+                if (!initialized) return
+                val model = modelList.getOrNull(pos) ?: return
+                Prefs.saveModel(this@SetupActivity, selectedProvider.id, model.id)
+                updateStatus()
+            }
+            override fun onNothingSelected(parent: AdapterView<*>?) {}
+        }
+
+        val cached = Prefs.cachedModels(this, selectedProvider.id)
+        if (cached != null) populateModelSpinner(cached)
+        else populateModelSpinner(listOf(ModelInfo(selectedProvider.defaultModel, selectedProvider.defaultModel)))
+
+        maybeAutoFetch()
+    }
+
+    private fun populateModelSpinner(models: List<ModelInfo>) {
+        modelList = models
+        val savedModel = Prefs.model(this, selectedProvider.id)
+        val adapter = ArrayAdapter(
+            this,
+            android.R.layout.simple_spinner_item,
+            models.map { it.displayName },
+        ).apply { setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
+
+        val prev = initialized
+        initialized = false
+        modelSpinner.adapter = adapter
+        val idx = models.indexOfFirst { it.id == savedModel }.coerceAtLeast(0)
+        modelSpinner.setSelection(idx)
+        initialized = prev
+    }
+
+    // ── Model fetch ────────────────────────────────────────────────────────────
+
+    private fun maybeAutoFetch() {
+        val provider = selectedProvider
+        val canFetch = if (provider.requiresApiKey) {
+            Prefs.hasApiKey(this, provider.id)
+        } else {
+            !provider.allowsBaseUrlOverride || Prefs.baseUrl(this, provider.id).isNotBlank()
+        }
+        if (canFetch && Prefs.cachedModels(this, provider.id) == null) fetchModels()
+    }
+
+    private fun fetchModels() {
+        val provider = selectedProvider
+        val apiKey = Prefs.apiKey(this, provider.id)
+        val baseUrl = Prefs.baseUrl(this, provider.id)
+        statusView.text = "Fetching models…"
+
+        provider.listModels(apiKey, baseUrl) { models ->
+            runOnUiThread {
+                if (!models.isNullOrEmpty()) {
+                    Prefs.saveModelCache(this, provider.id, models)
+                    populateModelSpinner(models)
+                    Toast.makeText(this, "${models.size} models loaded.", Toast.LENGTH_SHORT).show()
+                } else {
+                    Toast.makeText(this, "Couldn't fetch models.", Toast.LENGTH_SHORT).show()
+                }
+                updateStatus()
+            }
+        }
+    }
+
+    // ── UI refresh ─────────────────────────────────────────────────────────────
+
     private fun refreshUI() {
-        val hasKey = Prefs.hasApiKey(this)
-        enterKeyGroup.visibility = if (hasKey) View.GONE else View.VISIBLE
-        clearBtn.visibility = if (hasKey) View.VISIBLE else View.GONE
+        val provider = selectedProvider
+        val hasKey = Prefs.hasApiKey(this, provider.id)
+
+        apiKeyGroup.visibility = if (provider.requiresApiKey) View.VISIBLE else View.GONE
+        saveKeyBtn.visibility  = if (!hasKey) View.VISIBLE else View.GONE
+        clearKeyBtn.visibility = if (hasKey) View.VISIBLE else View.GONE
 
+        if (provider.allowsBaseUrlOverride) {
+            baseUrlGroup.visibility = View.VISIBLE
+            baseUrlInput.setText(Prefs.baseUrl(this, provider.id))
+        } else {
+            baseUrlGroup.visibility = View.GONE
+        }
+
+        val cached = Prefs.cachedModels(this, provider.id)
+        if (cached != null) populateModelSpinner(cached)
+        else populateModelSpinner(listOf(ModelInfo(provider.defaultModel, provider.defaultModel)))
+
+        updateStatus()
+    }
+
+    private fun updateStatus() {
+        val provider = selectedProvider
+        val hasKey = !provider.requiresApiKey || Prefs.hasApiKey(this, provider.id)
+        val hasUrl = !provider.allowsBaseUrlOverride || Prefs.baseUrl(this, provider.id).isNotBlank()
+        val hasModel = Prefs.model(this, provider.id).isNotBlank()
         val micOk = hasMic()
+
         statusView.text = buildString {
-            appendLine(if (hasKey) "✅ API key saved" else "❌ No API key — paste one above and tap Save")
+            if (provider.requiresApiKey)
+                appendLine(if (hasKey) "✅ API key saved" else "❌ No API key — paste one above and tap Save")
+            if (provider.allowsBaseUrlOverride)
+                appendLine(if (hasUrl) "✅ Base URL set" else "❌ Base URL required")
+            appendLine(if (hasModel) "✅ Model: ${Prefs.model(this@SetupActivity, provider.id)}" else "❌ No model selected — tap ↻ to fetch")
             appendLine(if (micOk) "✅ Microphone permission granted" else "❌ Microphone permission denied")
             appendLine()
             appendLine(
-                if (hasKey && micOk)
-                    "Ready. Connect Android Auto and open Claude Auto from the car launcher."
+                if (hasKey && hasUrl && hasModel && micOk)
+                    "Ready. Connect Android Auto and open the app from the car launcher."
                 else
                     "Fix the issues above before using the car app."
             )
diff --git a/app/src/main/res/layout/activity_setup.xml b/app/src/main/res/layout/activity_setup.xml
index 26c804d..30eb390 100644
--- a/app/src/main/res/layout/activity_setup.xml
+++ b/app/src/main/res/layout/activity_setup.xml
@@ -1,64 +1,165 @@
 <?xml version="1.0" encoding="utf-8"?>
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    android:orientation="vertical"
-    android:padding="32dp"
-    android:gravity="top">
+    android:layout_height="match_parent">
 
-    <TextView
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="Claude Auto"
-        android:textSize="26sp"
-        android:textStyle="bold"
-        android:layout_marginBottom="8dp" />
-
-    <!-- Shown only when no key is saved -->
     <LinearLayout
-        android:id="@+id/enterKeyGroup"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:orientation="vertical">
+        android:orientation="vertical"
+        android:padding="24dp">
+
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="AI Auto"
+            android:textSize="26sp"
+            android:textStyle="bold"
+            android:layout_marginBottom="4dp" />
 
         <TextView
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:text="Paste your API key from console.anthropic.com"
+            android:text="Configure your AI provider"
             android:textSize="14sp"
+            android:layout_marginBottom="24dp" />
+
+        <!-- Provider -->
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="Provider"
+            android:textSize="12sp"
+            android:textStyle="bold"
+            android:layout_marginBottom="4dp" />
+
+        <Spinner
+            android:id="@+id/providerSpinner"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
             android:layout_marginBottom="20dp" />
 
-        <EditText
-            android:id="@+id/keyInput"
+        <!-- API key (hidden when provider doesn't require one) -->
+        <LinearLayout
+            android:id="@+id/apiKeyGroup"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:hint="sk-ant-..."
-            android:inputType="textPassword"
-            android:fontFamily="monospace"
-            android:layout_marginBottom="12dp" />
+            android:orientation="vertical"
+            android:layout_marginBottom="20dp">
+
+            <TextView
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="API Key"
+                android:textSize="12sp"
+                android:textStyle="bold"
+                android:layout_marginBottom="4dp" />
+
+            <EditText
+                android:id="@+id/keyInput"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:hint="Paste your API key"
+                android:inputType="textPassword"
+                android:fontFamily="monospace"
+                android:layout_marginBottom="8dp" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:orientation="horizontal">
 
-        <Button
-            android:id="@+id/saveBtn"
+                <Button
+                    android:id="@+id/saveKeyBtn"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:text="Save Key"
+                    android:layout_marginEnd="8dp" />
+
+                <Button
+                    android:id="@+id/clearKeyBtn"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:text="Clear Key"
+                    android:visibility="gone" />
+
+            </LinearLayout>
+
+        </LinearLayout>
+
+        <!-- Base URL (shown for Ollama / Custom) -->
+        <LinearLayout
+            android:id="@+id/baseUrlGroup"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:text="Save API Key"
-            android:layout_marginBottom="24dp" />
+            android:orientation="vertical"
+            android:visibility="gone"
+            android:layout_marginBottom="20dp">
 
-    </LinearLayout>
+            <TextView
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Base URL"
+                android:textSize="12sp"
+                android:textStyle="bold"
+                android:layout_marginBottom="4dp" />
 
-    <!-- Shown only when a key is already saved -->
-    <Button
-        android:id="@+id/clearBtn"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:text="Clear Saved Key"
-        android:layout_marginBottom="24dp" />
+            <EditText
+                android:id="@+id/baseUrlInput"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:inputType="textUri"
+                android:fontFamily="monospace"
+                android:layout_marginBottom="8dp" />
 
-    <TextView
-        android:id="@+id/status"
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:textSize="14sp"
-        android:lineSpacingMultiplier="1.4" />
+            <Button
+                android:id="@+id/saveUrlBtn"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Save URL" />
+
+        </LinearLayout>
+
+        <!-- Model -->
+        <TextView
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="Model"
+            android:textSize="12sp"
+            android:textStyle="bold"
+            android:layout_marginBottom="4dp" />
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal"
+            android:layout_marginBottom="24dp">
+
+            <Spinner
+                android:id="@+id/modelSpinner"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:layout_marginEnd="8dp" />
+
+            <Button
+                android:id="@+id/fetchModelsBtn"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="↻" />
+
+        </LinearLayout>
+
+        <!-- Status -->
+        <TextView
+            android:id="@+id/status"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:textSize="14sp"
+            android:lineSpacingMultiplier="1.4" />
+
+    </LinearLayout>
 
-</LinearLayout>
+</ScrollView>