The Problem
Tagmind's Ask AI tab lets you pick a provider (Gemini, Claude, ChatGPT, Perplexity), type a question, and get an answer. That worked fine โ but each provider has several models at very different price points.
Gemini has gemini-2.5-flash (free tier) and gemini-2.5-pro (paid). Claude has Haiku, Sonnet, and Opus. Locking users to one model per provider means either always paying for the expensive one or always being stuck with the cheap one.
The fix: a model dropdown directly in the search bar.
Why Hardcoded Lists, Not a Dynamic Fetch
The obvious move would be to call each provider's model list API and populate the dropdown dynamically.
I decided against it:
- Every provider has a different response format. Gemini, OpenAI, and Anthropic all return model lists differently. Perplexity doesn't have a public models endpoint at all.
- It adds a network call every time you switch providers.
- The list of "models worth showing" is smaller than the full API response anyway โ you don't want to show deprecated or fine-tuned variants.
A curated hardcoded list is what most AI client apps use in practice, and it's predictable. Users don't need to know about every internal model variant.
The final list:
val providerModels: Map<LlmProvider, List<LlmModel>> = mapOf(
LlmProvider.GEMINI to listOf(
LlmModel("gemini-2.5-flash", "2.5 Flash"),
LlmModel("gemini-2.5-pro", "2.5 Pro"),
LlmModel("gemini-2.0-flash-lite", "2.0 Flash Lite"),
),
LlmProvider.CLAUDE to listOf(
LlmModel("claude-haiku-4-5-20251001", "Haiku 4.5"),
LlmModel("claude-sonnet-4-6", "Sonnet 4.6"),
LlmModel("claude-opus-4-8", "Opus 4.8"),
),
LlmProvider.CHATGPT to listOf(
LlmModel("gpt-4o-mini", "4o mini"),
LlmModel("gpt-4o", "4o"),
LlmModel("gpt-4.1", "4.1"),
),
LlmProvider.PERPLEXITY to listOf(
LlmModel("sonar", "Sonar"),
LlmModel("sonar-pro", "Sonar Pro"),
),
)Default is always the first entry โ cheapest and fastest.
What Changed in SearchViewModel
Added selectedModel: StateFlow<LlmModel?> and an onModelChange() handler. When the provider changes, the model resets to that provider's default automatically:
fun onProviderChange(p: LlmProvider) {
_selectedProvider.value = p
_selectedModel.value = providerModels[p]?.firstOrNull()
}ask() now passes the selected model ID down to the service:
val model = _selectedModel.value?.id
?: providerModels[provider]?.firstOrNull()?.id
?: return
val answer = llmSearchService.search(provider, model, notes, q)The UI
The model dropdown lives in the left side of the search bar, but only appears when a configured (validated) provider is selected. If no provider is configured, the spot shows the usual sparkle icon instead.
if (selectedProvider in configuredProviders) {
Box {
Row(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
.clickable { menuOpen = true }
.padding(horizontal = 8.dp, vertical = 5.dp),
...
) {
Text(selectedModel?.displayName ?: models.firstOrNull()?.displayName ?: "")
Icon(Icons.Default.KeyboardArrowDown, ...)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
models.forEach { model ->
DropdownMenuItem(
text = { Text(model.displayName, color = if (model == selectedModel) primary else default) },
onClick = { viewModel.onModelChange(model); menuOpen = false },
)
}
}
}
}The answer card now also shows the model name alongside the provider name โ Claude ยท Haiku 4.5 โ so you know exactly what answered.
The Bug That Showed Up During This
While testing the LLM calls, I noticed all providers were returning "network error" on the first Save in Settings. The keys were being stored correctly, validation was running โ but always failing.
The cause: OkHttpClient.execute() is a blocking call. My validateKey() was running inside viewModelScope.launch { }, which dispatches on Dispatchers.Main by default. Android throws NetworkOnMainThreadException for any blocking network call on the main thread. That got caught by the catch (e: Exception) block and shown as "Network error."
Fix was straightforward โ wrap every OkHttp call in withContext(Dispatchers.IO):
private suspend fun callClaude(key: String, prompt: String, model: String, maxTokens: Int): String =
withContext(Dispatchers.IO) {
// OkHttp blocking call here โ safe on IO thread
}One thing to watch: return is not allowed inside a withContext { } lambda. The block is a lambda expression, not a function body โ the last expression is the return value. Replace early returns with if/else expressions.
What I Learned
Curated lists beat dynamic fetches for known external APIs. The overhead of normalizing four different provider response schemas, handling the case where one API is down, and explaining to users why their model list is empty isn't worth it when the set of useful models changes maybe twice a year.
Provider switching should always reset dependent state. When the user switches from Claude to Gemini, keeping selectedModel = "claude-opus-4-8" would be silent data corruption โ the model ID would be sent to Gemini's API and fail in an opaque way. Resetting to the new provider's default in onProviderChange() keeps it clean.
withContext(Dispatchers.IO) is not optional for OkHttp. It's easy to forget when the code looks async (suspend functions, coroutine scope) but the underlying call is still blocking. The error message gives no hint โ "network error" looks like a connectivity problem, not a threading one.