RFC-003: Model/Provider Management
RFC-003: Model/Provider Management
Document Information
- RFC ID: RFC-003
- Related PRD: FEAT-003 (Model/Provider Management)
- Related Design: UI Design Spec (Sections 3, 4, 7)
- Related Architecture: RFC-000 (Overall Architecture)
- Created: 2026-02-27
- Last Updated: 2026-02-27 (updated with implementation fixes from Layer 2 testing)
- Status: Draft
- Author: TBD
Overview
Background
Model/Provider Management is the foundational feature that enables all AI interactions in OneClaw. Before the user can send any message, they must have at least one provider configured with a valid API key. This RFC specifies the technical implementation for provider CRUD operations, API key secure storage, model list fetching, connection testing, the first-time setup flow, and the global default model selection.
This RFC covers the provider management infrastructure only – it defines the ModelApiAdapter interface in full but only implements listModels() and testConnection(). The sendMessageStream() implementation is deferred to RFC-001 (Chat Interaction).
Goals
- Implement provider and model data persistence (Room entities, DAOs, repository)
- Implement secure API key storage via EncryptedSharedPreferences
- Implement provider API adapters for OpenAI, Anthropic, and Google Gemini (list-models and test-connection)
- Implement pre-configured provider seeding on first launch
- Implement connection testing with clear success/failure feedback
- Implement dynamic model list fetching with preset fallback
- Implement the global default model selection and persistence
- Implement provider management UI (list screen, detail screen, setup screen)
- Provide enough implementation detail for AI-assisted code generation
Non-Goals
- Streaming chat message sending (
sendMessageStreamimplementation) – deferred to RFC-001 - Tool definition formatting for API calls – deferred to RFC-004
- SSE streaming implementation – deferred to RFC-001
- OAuth-based provider authentication
- Multi-key per provider
- Provider usage analytics
- Data sync / backup of provider configurations
Technical Design
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ UI Layer │
│ SetupScreen ProviderListScreen ProviderDetailScreen │
│ │ │ │ │
│ v v v │
│ (shared) ProviderListViewModel ProviderDetailViewModel │
├─────────────────────────────────────────────────────────────┤
│ Domain Layer │
│ TestConnectionUseCase FetchModelsUseCase │
│ SetDefaultModelUseCase SeedProvidersUseCase │
│ │ │
│ v │
│ ProviderRepository (interface) │
├─────────────────────────────────────────────────────────────┤
│ Data Layer │
│ ProviderRepositoryImpl │
│ │ │ │ │
│ v v v │
│ ProviderDao ApiKeyStorage ModelApiAdapterFactory │
│ ModelDao ├── OpenAiAdapter │
│ ├── AnthropicAdapter │
│ └── GeminiAdapter │
└─────────────────────────────────────────────────────────────┘
Core Components
- ApiKeyStorage
- Responsibility: Secure read/write/delete of API keys
- Backed by: EncryptedSharedPreferences (Android Keystore)
- Interface:
getApiKey(providerId),setApiKey(providerId, key),deleteApiKey(providerId),hasApiKey(providerId)
- ProviderRepositoryImpl
- Responsibility: Orchestrates provider CRUD, model operations, delegates to DAO and adapters
- Dependencies: ProviderDao, ModelDao, ApiKeyStorage, ModelApiAdapterFactory, SettingsDao
- ModelApiAdapter (interface) + Implementations
- Responsibility: Abstracts provider-specific API formats
- Implementations: OpenAiAdapter, AnthropicAdapter, GeminiAdapter
- This RFC implements:
listModels(),testConnection() - Deferred to RFC-001:
sendMessageStream()
- Pre-configured Provider Seeder
- Responsibility: Inserts 3 built-in provider templates into Room on first DB creation
- Mechanism: Room
RoomDatabase.Callback.onCreate
- Use Cases
TestConnectionUseCase: Validates API key + endpoint reachabilityFetchModelsUseCase: Fetches model list from provider API with preset fallbackSetDefaultModelUseCase: Sets the global default model/providerSeedProvidersUseCase: Not a traditional use case – handled by DB callback (see below)
Data Model
Domain Models
These models are defined in the Core module (core/model/). The Provider and AiModel models are already defined in RFC-000. This section documents the updated definitions.
Provider (Updated)
data class Provider(
val id: String, // UUID
val name: String, // Display name (e.g., "OpenAI", "My Local Server")
val type: ProviderType, // API protocol format: OPENAI, ANTHROPIC, GEMINI
val apiBaseUrl: String, // Base URL for API requests
val isPreConfigured: Boolean, // true = built-in template, false = user-created
val isActive: Boolean, // Whether this provider is enabled
val createdAt: Long, // Timestamp millis
val updatedAt: Long // Timestamp millis
)
enum class ProviderType {
OPENAI, // OpenAI-compatible API format (also used for custom OpenAI-compatible endpoints)
ANTHROPIC, // Anthropic API format
GEMINI // Google Gemini API format
}
// NOTE: There is no CUSTOM type. Custom endpoints choose OPENAI, ANTHROPIC, or GEMINI
// based on which API protocol they are compatible with. The `isPreConfigured` field
// distinguishes built-in templates from user-created providers.
Change from RFC-000: Removed ProviderType.CUSTOM. The type field now represents the API protocol format, not the service identity. User-created providers pick OPENAI, ANTHROPIC, or GEMINI based on which API format their endpoint is compatible with. isPreConfigured distinguishes built-in from user-created providers.
AiModel (No Change)
data class AiModel(
val id: String, // Model identifier (e.g., "gpt-4o")
val displayName: String?, // Human-friendly name (e.g., "GPT-4o")
val providerId: String, // Which provider this belongs to
val isDefault: Boolean, // Whether this is the global default
val source: ModelSource // How this model was added
)
enum class ModelSource {
DYNAMIC, // Fetched from provider API
PRESET, // Pre-configured fallback
MANUAL // User-added
}
ConnectionTestResult (New)
data class ConnectionTestResult(
val success: Boolean,
val modelCount: Int?, // Number of models found (on success)
val errorType: ConnectionErrorType?,
val errorMessage: String?
)
enum class ConnectionErrorType {
AUTH_FAILURE, // 401/403 -- invalid API key
NETWORK_FAILURE, // Cannot reach the server
TIMEOUT, // Request timed out
UNKNOWN // Other error
}
Room Entities
ProviderEntity
@Entity(tableName = "providers")
data class ProviderEntity(
@PrimaryKey
val id: String,
val name: String,
@ColumnInfo(name = "type")
val type: String, // "OPENAI", "ANTHROPIC", "GEMINI"
@ColumnInfo(name = "api_base_url")
val apiBaseUrl: String,
@ColumnInfo(name = "is_pre_configured")
val isPreConfigured: Boolean,
@ColumnInfo(name = "is_active")
val isActive: Boolean,
@ColumnInfo(name = "created_at")
val createdAt: Long,
@ColumnInfo(name = "updated_at")
val updatedAt: Long
)
ModelEntity
@Entity(
tableName = "models",
primaryKeys = ["id", "provider_id"],
foreignKeys = [
ForeignKey(
entity = ProviderEntity::class,
parentColumns = ["id"],
childColumns = ["provider_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index("provider_id")]
)
data class ModelEntity(
val id: String,
@ColumnInfo(name = "display_name")
val displayName: String?,
@ColumnInfo(name = "provider_id")
val providerId: String,
@ColumnInfo(name = "is_default")
val isDefault: Boolean,
val source: String // "DYNAMIC", "PRESET", "MANUAL"
)
Database Schema
CREATE TABLE providers (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL, -- "OPENAI", "ANTHROPIC", "GEMINI"
api_base_url TEXT NOT NULL,
is_pre_configured INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE models (
id TEXT NOT NULL,
display_name TEXT,
provider_id TEXT NOT NULL,
is_default INTEGER NOT NULL DEFAULT 0,
source TEXT NOT NULL, -- "DYNAMIC", "PRESET", "MANUAL"
PRIMARY KEY (id, provider_id),
FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
);
CREATE INDEX idx_models_provider_id ON models(provider_id);
Entity-Domain Mappers
// ProviderMapper.kt
fun ProviderEntity.toDomain(): Provider = Provider(
id = id,
name = name,
type = ProviderType.valueOf(type),
apiBaseUrl = apiBaseUrl,
isPreConfigured = isPreConfigured,
isActive = isActive,
createdAt = createdAt,
updatedAt = updatedAt
)
fun Provider.toEntity(): ProviderEntity = ProviderEntity(
id = id,
name = name,
type = type.name,
apiBaseUrl = apiBaseUrl,
isPreConfigured = isPreConfigured,
isActive = isActive,
createdAt = createdAt,
updatedAt = updatedAt
)
// ModelMapper.kt
fun ModelEntity.toDomain(): AiModel = AiModel(
id = id,
displayName = displayName,
providerId = providerId,
isDefault = isDefault,
source = ModelSource.valueOf(source)
)
fun AiModel.toEntity(): ModelEntity = ModelEntity(
id = id,
displayName = displayName,
providerId = providerId,
isDefault = isDefault,
source = source.name
)
API Key Storage
ApiKeyStorage Class
API keys are stored in EncryptedSharedPreferences, completely separate from the Room database. This class is in the Data layer at data/security/ApiKeyStorage.kt.
class ApiKeyStorage(context: Context) {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
context,
"oneclaw_api_keys",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
// DEBUG builds support a plain-text fallback prefs file so that instrumented tests
// can inject API keys without requiring cross-process EncryptedSharedPreferences access.
// The fallback file "oneclaw_api_keys_debug" is written by SetupDataInjector in tests.
private val debugPrefs: SharedPreferences? = if (BuildConfig.DEBUG) {
context.getSharedPreferences("oneclaw_api_keys_debug", Context.MODE_PRIVATE)
} else null
fun getApiKey(providerId: String): String? {
// In DEBUG builds, the plain fallback takes priority (set by instrumented tests)
val debugKey = debugPrefs?.getString("api_key_$providerId", null)
if (debugKey != null) return debugKey
return prefs.getString("api_key_$providerId", null)
}
fun setApiKey(providerId: String, apiKey: String) {
prefs.edit().putString("api_key_$providerId", apiKey.trim()).apply()
}
fun deleteApiKey(providerId: String) {
prefs.edit().remove("api_key_$providerId").apply()
debugPrefs?.edit()?.remove("api_key_$providerId")?.apply()
}
fun hasApiKey(providerId: String): Boolean {
if (debugPrefs?.contains("api_key_$providerId") == true) return true
return prefs.contains("api_key_$providerId")
}
}
Key Design Decisions
- Key format:
api_key_{providerId}— simple, collision-free since providerId is a fixed string (e.g.,provider-anthropic) - Trim on save:
apiKey.trim()removes accidental whitespace (common user error when pasting keys) - EncryptedSharedPreferences: Uses AES256-GCM for value encryption, AES256-SIV for key encryption, backed by Android Keystore master key via
MasterKey.Builder - No caching: Reads directly from EncryptedSharedPreferences each time. The underlying implementation has its own caching, and API key reads are infrequent enough that this is not a performance concern.
- Debug fallback: In
BuildConfig.DEBUGbuilds, a plainSharedPreferencesfile (oneclaw_api_keys_debug) is checked first. This allows instrumented tests to inject API keys without needing to interact withEncryptedSharedPreferencescross-process. The fallback is inert in release builds. buildConfig = truerequired:BuildConfig.DEBUGis only generated ifbuildFeatures { buildConfig = true }is set inapp/build.gradle.kts.
DAO Interfaces
ProviderDao
@Dao
interface ProviderDao {
@Query("SELECT * FROM providers ORDER BY is_pre_configured DESC, created_at ASC")
fun getAllProviders(): Flow<List<ProviderEntity>>
@Query("SELECT * FROM providers WHERE id = :id")
suspend fun getProviderById(id: String): ProviderEntity?
@Query("SELECT * FROM providers WHERE is_pre_configured = 1")
suspend fun getPreConfiguredProviders(): List<ProviderEntity>
@Query("SELECT * FROM providers WHERE is_active = 1")
fun getActiveProviders(): Flow<List<ProviderEntity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProvider(provider: ProviderEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProviders(providers: List<ProviderEntity>)
@Update
suspend fun updateProvider(provider: ProviderEntity)
@Query("DELETE FROM providers WHERE id = :id AND is_pre_configured = 0")
suspend fun deleteCustomProvider(id: String): Int
@Query("UPDATE providers SET is_active = :isActive, updated_at = :updatedAt WHERE id = :id")
suspend fun setProviderActive(id: String, isActive: Boolean, updatedAt: Long)
}
ModelDao
@Dao
interface ModelDao {
@Query("SELECT * FROM models WHERE provider_id = :providerId")
suspend fun getModelsForProvider(providerId: String): List<ModelEntity>
@Query("SELECT * FROM models WHERE is_default = 1 LIMIT 1")
fun getDefaultModel(): Flow<ModelEntity?>
@Query("SELECT * FROM models WHERE id = :modelId AND provider_id = :providerId")
suspend fun getModel(modelId: String, providerId: String): ModelEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertModels(models: List<ModelEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertModel(model: ModelEntity)
@Query("DELETE FROM models WHERE provider_id = :providerId AND source = :source")
suspend fun deleteModelsBySource(providerId: String, source: String)
@Query("DELETE FROM models WHERE provider_id = :providerId")
suspend fun deleteAllModelsForProvider(providerId: String)
@Query("UPDATE models SET is_default = 0 WHERE is_default = 1")
suspend fun clearDefaultModel()
@Query("UPDATE models SET is_default = 1 WHERE id = :modelId AND provider_id = :providerId")
suspend fun setDefaultModel(modelId: String, providerId: String)
@Transaction
suspend fun updateDefaultModel(modelId: String, providerId: String) {
clearDefaultModel()
setDefaultModel(modelId, providerId)
}
}
Provider API Adapters
ModelApiAdapter Interface
This is the full interface. sendMessageStream() is declared here but implemented in RFC-001.
interface ModelApiAdapter {
/**
* Fetch available models from the provider API.
*
* @param apiBaseUrl Base URL for the provider API (e.g., "https://api.openai.com/v1")
* @param apiKey The user's API key for this provider
* @return List of available models on success, or error
*/
suspend fun listModels(
apiBaseUrl: String,
apiKey: String
): AppResult<List<AiModel>>
/**
* Test connection to the provider. Typically calls listModels internally.
*
* @return ConnectionTestResult with success/failure details
*/
suspend fun testConnection(
apiBaseUrl: String,
apiKey: String
): AppResult<ConnectionTestResult>
/**
* Send a chat completion request with streaming response.
* IMPLEMENTATION DEFERRED TO RFC-001.
*/
fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?
): Flow<StreamEvent>
}
ModelApiAdapterFactory
class ModelApiAdapterFactory(private val okHttpClient: OkHttpClient) {
fun getAdapter(providerType: ProviderType): ModelApiAdapter {
return when (providerType) {
ProviderType.OPENAI -> OpenAiAdapter(okHttpClient)
ProviderType.ANTHROPIC -> AnthropicAdapter(okHttpClient)
ProviderType.GEMINI -> GeminiAdapter(okHttpClient)
}
}
}
OpenAI Adapter
List Models API
GET {apiBaseUrl}/models
Headers:
Authorization: Bearer {apiKey}
Response 200:
{
"data": [
{
"id": "gpt-4o",
"object": "model",
"owned_by": "openai"
},
...
]
}
DTOs
// data/remote/dto/openai/
@Serializable
data class OpenAiModelListResponse(
val data: List<OpenAiModelDto>
)
@Serializable
data class OpenAiModelDto(
val id: String,
@SerialName("owned_by")
val ownedBy: String? = null
)
Implementation
class OpenAiAdapter(private val client: OkHttpClient) : ModelApiAdapter {
override suspend fun listModels(
apiBaseUrl: String,
apiKey: String
): AppResult<List<AiModel>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("${apiBaseUrl.trimEnd('/')}/models")
.addHeader("Authorization", "Bearer $apiKey")
.get()
.build()
val response = client.newCall(request).execute()
when {
response.isSuccessful -> {
val body = response.body?.string()
?: return@withContext AppResult.Error(
message = "Empty response body",
code = ErrorCode.PROVIDER_ERROR
)
val parsed = Json.decodeFromString<OpenAiModelListResponse>(body)
val models = parsed.data
.filter { isRelevantOpenAiModel(it.id) }
.map { dto ->
AiModel(
id = dto.id,
displayName = formatOpenAiModelName(dto.id),
providerId = "", // Set by caller
isDefault = false,
source = ModelSource.DYNAMIC
)
}
AppResult.Success(models)
}
response.code == 401 || response.code == 403 -> {
AppResult.Error(
message = "Authentication failed. Please check your API key.",
code = ErrorCode.AUTH_ERROR
)
}
else -> {
AppResult.Error(
message = "API error: ${response.code} ${response.message}",
code = ErrorCode.PROVIDER_ERROR
)
}
}
} catch (e: java.net.UnknownHostException) {
AppResult.Error(
message = "Cannot reach the server. Please check the URL and your network.",
code = ErrorCode.NETWORK_ERROR,
exception = e
)
} catch (e: java.net.SocketTimeoutException) {
AppResult.Error(
message = "Connection timed out.",
code = ErrorCode.TIMEOUT_ERROR,
exception = e
)
} catch (e: Exception) {
AppResult.Error(
message = "Unexpected error: ${e.message}",
code = ErrorCode.UNKNOWN,
exception = e
)
}
}
override suspend fun testConnection(
apiBaseUrl: String,
apiKey: String
): AppResult<ConnectionTestResult> {
return when (val result = listModels(apiBaseUrl, apiKey)) {
is AppResult.Success -> {
AppResult.Success(
ConnectionTestResult(
success = true,
modelCount = result.data.size,
errorType = null,
errorMessage = null
)
)
}
is AppResult.Error -> {
val errorType = when (result.code) {
ErrorCode.AUTH_ERROR -> ConnectionErrorType.AUTH_FAILURE
ErrorCode.NETWORK_ERROR -> ConnectionErrorType.NETWORK_FAILURE
ErrorCode.TIMEOUT_ERROR -> ConnectionErrorType.TIMEOUT
else -> ConnectionErrorType.UNKNOWN
}
AppResult.Success(
ConnectionTestResult(
success = false,
modelCount = null,
errorType = errorType,
errorMessage = result.message
)
)
}
}
}
override fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?
): Flow<StreamEvent> {
// Implementation deferred to RFC-001
throw NotImplementedError("sendMessageStream is implemented in RFC-001")
}
/**
* Filter out non-chat models (embeddings, whisper, tts, dall-e, etc.)
* Only keep models that are useful for chat completion.
*/
private fun isRelevantOpenAiModel(modelId: String): Boolean {
val chatPrefixes = listOf("gpt-", "o1", "o3", "o4", "chatgpt-")
return chatPrefixes.any { modelId.startsWith(it) }
}
/**
* Format model ID into a human-friendly display name.
* e.g., "gpt-4o" -> "GPT-4o", "gpt-4o-mini" -> "GPT-4o Mini"
*/
private fun formatOpenAiModelName(modelId: String): String {
return modelId
.replace("gpt-", "GPT-")
.replace("-mini", " Mini")
}
}
Anthropic Adapter
List Models API
GET {apiBaseUrl}/models
Headers:
x-api-key: {apiKey}
anthropic-version: 2023-06-01
Response 200:
{
"data": [
{
"id": "claude-sonnet-4-20250514",
"display_name": "Claude Sonnet 4",
"type": "model"
},
...
]
}
DTOs
// data/remote/dto/anthropic/
@Serializable
data class AnthropicModelListResponse(
val data: List<AnthropicModelDto>
)
@Serializable
data class AnthropicModelDto(
val id: String,
@SerialName("display_name")
val displayName: String? = null,
val type: String? = null
)
Implementation
class AnthropicAdapter(private val client: OkHttpClient) : ModelApiAdapter {
companion object {
private const val ANTHROPIC_VERSION = "2023-06-01"
}
override suspend fun listModels(
apiBaseUrl: String,
apiKey: String
): AppResult<List<AiModel>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder()
.url("${apiBaseUrl.trimEnd('/')}/models")
.addHeader("x-api-key", apiKey)
.addHeader("anthropic-version", ANTHROPIC_VERSION)
.get()
.build()
val response = client.newCall(request).execute()
when {
response.isSuccessful -> {
val body = response.body?.string()
?: return@withContext AppResult.Error(
message = "Empty response body",
code = ErrorCode.PROVIDER_ERROR
)
val parsed = Json.decodeFromString<AnthropicModelListResponse>(body)
val models = parsed.data
.filter { it.type == "model" }
.map { dto ->
AiModel(
id = dto.id,
displayName = dto.displayName,
providerId = "", // Set by caller
isDefault = false,
source = ModelSource.DYNAMIC
)
}
AppResult.Success(models)
}
response.code == 401 || response.code == 403 -> {
AppResult.Error(
message = "Authentication failed. Please check your API key.",
code = ErrorCode.AUTH_ERROR
)
}
else -> {
AppResult.Error(
message = "API error: ${response.code} ${response.message}",
code = ErrorCode.PROVIDER_ERROR
)
}
}
} catch (e: java.net.UnknownHostException) {
AppResult.Error(message = "Cannot reach the server.", code = ErrorCode.NETWORK_ERROR, exception = e)
} catch (e: java.net.SocketTimeoutException) {
AppResult.Error(message = "Connection timed out.", code = ErrorCode.TIMEOUT_ERROR, exception = e)
} catch (e: Exception) {
AppResult.Error(message = "Unexpected error: ${e.message}", code = ErrorCode.UNKNOWN, exception = e)
}
}
override suspend fun testConnection(
apiBaseUrl: String,
apiKey: String
): AppResult<ConnectionTestResult> {
return when (val result = listModels(apiBaseUrl, apiKey)) {
is AppResult.Success -> AppResult.Success(
ConnectionTestResult(success = true, modelCount = result.data.size, errorType = null, errorMessage = null)
)
is AppResult.Error -> {
val errorType = when (result.code) {
ErrorCode.AUTH_ERROR -> ConnectionErrorType.AUTH_FAILURE
ErrorCode.NETWORK_ERROR -> ConnectionErrorType.NETWORK_FAILURE
ErrorCode.TIMEOUT_ERROR -> ConnectionErrorType.TIMEOUT
else -> ConnectionErrorType.UNKNOWN
}
AppResult.Success(
ConnectionTestResult(success = false, modelCount = null, errorType = errorType, errorMessage = result.message)
)
}
}
}
override fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?
): Flow<StreamEvent> {
throw NotImplementedError("sendMessageStream is implemented in RFC-001")
}
}
Google Gemini Adapter
List Models API
GET {apiBaseUrl}/models?key={apiKey}
Response 200:
{
"models": [
{
"name": "models/gemini-2.0-flash",
"displayName": "Gemini 2.0 Flash",
"supportedGenerationMethods": ["generateContent", "countTokens"]
},
...
]
}
Note: Gemini uses a query parameter for the API key instead of a header.
DTOs
// data/remote/dto/gemini/
@Serializable
data class GeminiModelListResponse(
val models: List<GeminiModelDto>
)
@Serializable
data class GeminiModelDto(
val name: String, // e.g., "models/gemini-2.0-flash"
val displayName: String? = null,
val supportedGenerationMethods: List<String> = emptyList()
)
Implementation
class GeminiAdapter(private val client: OkHttpClient) : ModelApiAdapter {
override suspend fun listModels(
apiBaseUrl: String,
apiKey: String
): AppResult<List<AiModel>> = withContext(Dispatchers.IO) {
try {
val url = "${apiBaseUrl.trimEnd('/')}/models?key=$apiKey"
val request = Request.Builder()
.url(url)
.get()
.build()
val response = client.newCall(request).execute()
when {
response.isSuccessful -> {
val body = response.body?.string()
?: return@withContext AppResult.Error(
message = "Empty response body",
code = ErrorCode.PROVIDER_ERROR
)
val parsed = Json.decodeFromString<GeminiModelListResponse>(body)
val models = parsed.models
.filter { "generateContent" in it.supportedGenerationMethods }
.map { dto ->
val modelId = dto.name.removePrefix("models/")
AiModel(
id = modelId,
displayName = dto.displayName,
providerId = "", // Set by caller
isDefault = false,
source = ModelSource.DYNAMIC
)
}
AppResult.Success(models)
}
response.code == 401 || response.code == 403 || response.code == 400 -> {
// Gemini returns 400 for invalid API key in some cases
AppResult.Error(
message = "Authentication failed. Please check your API key.",
code = ErrorCode.AUTH_ERROR
)
}
else -> {
AppResult.Error(
message = "API error: ${response.code} ${response.message}",
code = ErrorCode.PROVIDER_ERROR
)
}
}
} catch (e: java.net.UnknownHostException) {
AppResult.Error(message = "Cannot reach the server.", code = ErrorCode.NETWORK_ERROR, exception = e)
} catch (e: java.net.SocketTimeoutException) {
AppResult.Error(message = "Connection timed out.", code = ErrorCode.TIMEOUT_ERROR, exception = e)
} catch (e: Exception) {
AppResult.Error(message = "Unexpected error: ${e.message}", code = ErrorCode.UNKNOWN, exception = e)
}
}
override suspend fun testConnection(
apiBaseUrl: String,
apiKey: String
): AppResult<ConnectionTestResult> {
return when (val result = listModels(apiBaseUrl, apiKey)) {
is AppResult.Success -> AppResult.Success(
ConnectionTestResult(success = true, modelCount = result.data.size, errorType = null, errorMessage = null)
)
is AppResult.Error -> {
val errorType = when (result.code) {
ErrorCode.AUTH_ERROR -> ConnectionErrorType.AUTH_FAILURE
ErrorCode.NETWORK_ERROR -> ConnectionErrorType.NETWORK_FAILURE
ErrorCode.TIMEOUT_ERROR -> ConnectionErrorType.TIMEOUT
else -> ConnectionErrorType.UNKNOWN
}
AppResult.Success(
ConnectionTestResult(success = false, modelCount = null, errorType = errorType, errorMessage = result.message)
)
}
}
}
override fun sendMessageStream(
apiBaseUrl: String,
apiKey: String,
modelId: String,
messages: List<ApiMessage>,
tools: List<ToolDefinition>?,
systemPrompt: String?
): Flow<StreamEvent> {
throw NotImplementedError("sendMessageStream is implemented in RFC-001")
}
}
Pre-configured Provider Seeding
Strategy: Room Database Callback
The three built-in provider templates (OpenAI, Anthropic, Google Gemini) are inserted into the Room database when the database is first created, via RoomDatabase.Callback.onCreate. This runs exactly once – when the database file does not yet exist.
Implementation
class AppDatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
val now = System.currentTimeMillis()
// Pre-configured provider templates
// These are inserted without API keys -- keys are managed by ApiKeyStorage
val providers = listOf(
// OpenAI
"""INSERT INTO providers (id, name, type, api_base_url, is_pre_configured, is_active, created_at, updated_at)
VALUES ('provider-openai', 'OpenAI', 'OPENAI', 'https://api.openai.com/v1', 1, 1, $now, $now)""",
// Anthropic
"""INSERT INTO providers (id, name, type, api_base_url, is_pre_configured, is_active, created_at, updated_at)
VALUES ('provider-anthropic', 'Anthropic', 'ANTHROPIC', 'https://api.anthropic.com/v1', 1, 1, $now, $now)""",
// Google Gemini
"""INSERT INTO providers (id, name, type, api_base_url, is_pre_configured, is_active, created_at, updated_at)
VALUES ('provider-gemini', 'Google Gemini', 'GEMINI', 'https://generativelanguage.googleapis.com/v1beta', 1, 1, $now, $now)"""
)
// Preset fallback models
val models = listOf(
// OpenAI preset models
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('gpt-4o', 'GPT-4o', 'provider-openai', 0, 'PRESET')""",
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('gpt-4o-mini', 'GPT-4o Mini', 'provider-openai', 0, 'PRESET')""",
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('o1', 'o1', 'provider-openai', 0, 'PRESET')""",
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('o3-mini', 'o3 Mini', 'provider-openai', 0, 'PRESET')""",
// Anthropic preset models
// NOTE: Use the correct versioned model IDs as they appear in the Anthropic API.
// These were corrected after Layer 2 testing found the old IDs did not exist.
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('claude-opus-4-5-20251101', 'Claude Opus 4.5', 'provider-anthropic', 0, 'PRESET')""",
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5', 'provider-anthropic', 0, 'PRESET')""",
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('claude-haiku-4-5-20251001', 'Claude Haiku 4.5', 'provider-anthropic', 0, 'PRESET')""",
// Gemini preset models
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('gemini-2.0-flash', 'Gemini 2.0 Flash', 'provider-gemini', 0, 'PRESET')""",
"""INSERT INTO models (id, display_name, provider_id, is_default, source)
VALUES ('gemini-2.5-pro', 'Gemini 2.5 Pro', 'provider-gemini', 0, 'PRESET')"""
)
providers.forEach { db.execSQL(it) }
models.forEach { db.execSQL(it) }
}
}
Design Decisions
- Fixed IDs: Pre-configured providers use deterministic IDs (
provider-openai,provider-anthropic,provider-gemini) instead of random UUIDs. This makes it easy to reference them in code and avoids duplication if seeding logic ever runs again. - No API keys in seed data: Providers are created in a “not configured” state. The user must add an API key to use them.
- Preset models are fallbacks: When the user adds an API key and the dynamic model list fetch succeeds, dynamic models are added alongside (not replacing) preset models. The UI can choose to show dynamic models first.
- All providers start active: Pre-configured providers are active by default. A provider without an API key is shown as “Not configured” in the UI but is still technically active.
Repository Implementation
ProviderRepository Interface
This is defined in Core module. Updated from RFC-000 to add addManualModel and deleteManualModel.
interface ProviderRepository {
fun getAllProviders(): Flow<List<Provider>>
fun getActiveProviders(): Flow<List<Provider>>
suspend fun getProviderById(id: String): Provider?
suspend fun createProvider(provider: Provider)
suspend fun updateProvider(provider: Provider)
suspend fun deleteProvider(id: String): AppResult<Unit>
suspend fun setProviderActive(id: String, isActive: Boolean)
suspend fun getModelsForProvider(providerId: String): List<AiModel>
suspend fun fetchModelsFromApi(providerId: String): AppResult<List<AiModel>>
suspend fun addManualModel(providerId: String, modelId: String, displayName: String?): AppResult<Unit>
suspend fun deleteManualModel(providerId: String, modelId: String): AppResult<Unit>
suspend fun testConnection(providerId: String): AppResult<ConnectionTestResult>
fun getGlobalDefaultModel(): Flow<AiModel?>
suspend fun setGlobalDefaultModel(modelId: String, providerId: String)
}
ProviderRepositoryImpl
class ProviderRepositoryImpl(
private val providerDao: ProviderDao,
private val modelDao: ModelDao,
private val apiKeyStorage: ApiKeyStorage,
private val adapterFactory: ModelApiAdapterFactory
) : ProviderRepository {
override fun getAllProviders(): Flow<List<Provider>> {
return providerDao.getAllProviders().map { entities ->
entities.map { it.toDomain() }
}
}
override fun getActiveProviders(): Flow<List<Provider>> {
return providerDao.getActiveProviders().map { entities ->
entities.map { it.toDomain() }
}
}
override suspend fun getProviderById(id: String): Provider? {
return providerDao.getProviderById(id)?.toDomain()
}
override suspend fun createProvider(provider: Provider) {
providerDao.insertProvider(provider.toEntity())
}
override suspend fun updateProvider(provider: Provider) {
providerDao.updateProvider(provider.toEntity())
}
override suspend fun deleteProvider(id: String): AppResult<Unit> {
// Check if this is the global default provider
val defaultModel = modelDao.getDefaultModel().first()
if (defaultModel != null && defaultModel.providerId == id) {
return AppResult.Error(
message = "Cannot delete the provider that has the global default model. Please set a different default model first.",
code = ErrorCode.VALIDATION_ERROR
)
}
// Delete API key
apiKeyStorage.deleteApiKey(id)
// Delete from DB (CASCADE will delete associated models)
val deleted = providerDao.deleteCustomProvider(id)
return if (deleted > 0) {
AppResult.Success(Unit)
} else {
AppResult.Error(
message = "Cannot delete pre-configured providers.",
code = ErrorCode.VALIDATION_ERROR
)
}
}
override suspend fun setProviderActive(id: String, isActive: Boolean) {
providerDao.setProviderActive(id, isActive, System.currentTimeMillis())
}
override suspend fun getModelsForProvider(providerId: String): List<AiModel> {
return modelDao.getModelsForProvider(providerId).map { it.toDomain() }
}
override suspend fun fetchModelsFromApi(providerId: String): AppResult<List<AiModel>> {
val provider = providerDao.getProviderById(providerId)?.toDomain()
?: return AppResult.Error(message = "Provider not found", code = ErrorCode.VALIDATION_ERROR)
val apiKey = apiKeyStorage.getApiKey(providerId)
?: return AppResult.Error(message = "No API key configured for this provider", code = ErrorCode.VALIDATION_ERROR)
val adapter = adapterFactory.getAdapter(provider.type)
val result = adapter.listModels(provider.apiBaseUrl, apiKey)
return when (result) {
is AppResult.Success -> {
val modelsWithProvider = result.data.map { it.copy(providerId = providerId) }
// Replace existing dynamic models, keep preset and manual models
modelDao.deleteModelsBySource(providerId, ModelSource.DYNAMIC.name)
modelDao.insertModels(modelsWithProvider.map { it.toEntity() })
AppResult.Success(modelsWithProvider)
}
is AppResult.Error -> result
}
}
override suspend fun addManualModel(
providerId: String,
modelId: String,
displayName: String?
): AppResult<Unit> {
val existing = modelDao.getModel(modelId, providerId)
if (existing != null) {
return AppResult.Error(
message = "Model '$modelId' already exists for this provider.",
code = ErrorCode.VALIDATION_ERROR
)
}
val model = AiModel(
id = modelId,
displayName = displayName,
providerId = providerId,
isDefault = false,
source = ModelSource.MANUAL
)
modelDao.insertModel(model.toEntity())
return AppResult.Success(Unit)
}
override suspend fun deleteManualModel(
providerId: String,
modelId: String
): AppResult<Unit> {
val model = modelDao.getModel(modelId, providerId)
if (model == null) {
return AppResult.Error(message = "Model not found", code = ErrorCode.VALIDATION_ERROR)
}
if (model.source != ModelSource.MANUAL.name) {
return AppResult.Error(
message = "Only manually added models can be deleted.",
code = ErrorCode.VALIDATION_ERROR
)
}
if (model.isDefault) {
return AppResult.Error(
message = "Cannot delete the global default model. Please change the default first.",
code = ErrorCode.VALIDATION_ERROR
)
}
modelDao.deleteModelsBySource(providerId, ModelSource.MANUAL.name)
return AppResult.Success(Unit)
}
override suspend fun testConnection(providerId: String): AppResult<ConnectionTestResult> {
val provider = providerDao.getProviderById(providerId)?.toDomain()
?: return AppResult.Error(message = "Provider not found", code = ErrorCode.VALIDATION_ERROR)
val apiKey = apiKeyStorage.getApiKey(providerId)
?: return AppResult.Error(message = "No API key configured", code = ErrorCode.VALIDATION_ERROR)
val adapter = adapterFactory.getAdapter(provider.type)
return adapter.testConnection(provider.apiBaseUrl, apiKey)
}
override fun getGlobalDefaultModel(): Flow<AiModel?> {
return modelDao.getDefaultModel().map { it?.toDomain() }
}
override suspend fun setGlobalDefaultModel(modelId: String, providerId: String) {
modelDao.updateDefaultModel(modelId, providerId)
}
}
Use Cases
TestConnectionUseCase
class TestConnectionUseCase(
private val providerRepository: ProviderRepository
) {
suspend operator fun invoke(providerId: String): AppResult<ConnectionTestResult> {
return providerRepository.testConnection(providerId)
}
}
FetchModelsUseCase
class FetchModelsUseCase(
private val providerRepository: ProviderRepository
) {
/**
* Fetch models from the provider API. If the fetch fails and the provider has
* no existing models, preset models are already in the DB from seeding.
* Returns the current model list (from DB) after the fetch attempt.
*/
suspend operator fun invoke(providerId: String): AppResult<List<AiModel>> {
// Attempt dynamic fetch
val fetchResult = providerRepository.fetchModelsFromApi(providerId)
// Regardless of fetch success/failure, return current model list from DB
val currentModels = providerRepository.getModelsForProvider(providerId)
return when (fetchResult) {
is AppResult.Success -> AppResult.Success(currentModels)
is AppResult.Error -> {
if (currentModels.isNotEmpty()) {
// Fetch failed, but we have existing models (preset or previously fetched)
AppResult.Success(currentModels)
} else {
// No models at all -- propagate error
fetchResult
}
}
}
}
}
SetDefaultModelUseCase
class SetDefaultModelUseCase(
private val providerRepository: ProviderRepository
) {
suspend operator fun invoke(modelId: String, providerId: String): AppResult<Unit> {
// Verify the model exists
val models = providerRepository.getModelsForProvider(providerId)
val model = models.find { it.id == modelId }
?: return AppResult.Error(
message = "Model not found",
code = ErrorCode.VALIDATION_ERROR
)
// Verify the provider is active
val provider = providerRepository.getProviderById(providerId)
?: return AppResult.Error(
message = "Provider not found",
code = ErrorCode.VALIDATION_ERROR
)
if (!provider.isActive) {
return AppResult.Error(
message = "Cannot set a default model from an inactive provider.",
code = ErrorCode.VALIDATION_ERROR
)
}
providerRepository.setGlobalDefaultModel(modelId, providerId)
return AppResult.Success(Unit)
}
}
UI Layer
Navigation Routes
// In Routes.kt (additions for provider feature)
object ProviderList : Route("providers")
object ProviderDetail : Route("providers/{providerId}") {
fun create(providerId: String) = "providers/$providerId"
}
object Setup : Route("setup")
UI State Definitions
ProviderListUiState
data class ProviderListUiState(
val providers: List<ProviderListItem> = emptyList(),
val isLoading: Boolean = true
)
data class ProviderListItem(
val id: String,
val name: String,
val type: ProviderType,
val modelCount: Int,
val isActive: Boolean,
val isPreConfigured: Boolean,
val hasApiKey: Boolean, // Whether an API key is configured
val connectionStatus: ConnectionStatus
)
enum class ConnectionStatus {
CONNECTED, // API key configured and last test was successful
NOT_CONFIGURED, // No API key set
DISCONNECTED // API key set but last test failed (or never tested)
}
ProviderDetailUiState
data class ProviderDetailUiState(
val provider: Provider? = null,
val models: List<AiModel> = emptyList(),
val globalDefaultModelId: String? = null,
val globalDefaultProviderId: String? = null,
// API Key
val apiKeyMasked: String = "", // e.g., "sk-...abc1234"
val apiKeyVisible: Boolean = false,
val apiKeyFull: String = "", // Full key (only loaded when visible)
val isEditingApiKey: Boolean = false,
val apiKeyInput: String = "", // Input field value while editing
// Connection test
val isTestingConnection: Boolean = false,
val connectionTestResult: ConnectionTestResult? = null,
// Model list
val isRefreshingModels: Boolean = false,
// Provider state
val isActive: Boolean = true,
val isPreConfigured: Boolean = false,
// Manual model add
val showAddModelDialog: Boolean = false,
val manualModelIdInput: String = "",
// General
val isLoading: Boolean = true,
val errorMessage: String? = null,
val successMessage: String? = null
)
SetupUiState
data class SetupUiState(
val step: SetupStep = SetupStep.CHOOSE_PROVIDER,
val selectedProviderType: ProviderType? = null,
val selectedProviderId: String? = null,
val apiKeyInput: String = "",
val isTestingConnection: Boolean = false,
val connectionTestResult: ConnectionTestResult? = null,
val models: List<AiModel> = emptyList(),
val selectedDefaultModelId: String? = null,
val errorMessage: String? = null
)
enum class SetupStep {
CHOOSE_PROVIDER, // Step 1: Pick OpenAI / Anthropic / Gemini / Custom
ENTER_API_KEY, // Step 2: Enter API key and test connection
SELECT_MODEL // Step 3: Select default model
}
ViewModels
ProviderListViewModel
class ProviderListViewModel(
private val providerRepository: ProviderRepository,
private val apiKeyStorage: ApiKeyStorage
) : ViewModel() {
private val _uiState = MutableStateFlow(ProviderListUiState())
val uiState: StateFlow<ProviderListUiState> = _uiState.asStateFlow()
init {
loadProviders()
}
private fun loadProviders() {
viewModelScope.launch {
providerRepository.getAllProviders().collect { providers ->
val items = providers.map { provider ->
val hasKey = apiKeyStorage.hasApiKey(provider.id)
val models = providerRepository.getModelsForProvider(provider.id)
ProviderListItem(
id = provider.id,
name = provider.name,
type = provider.type,
modelCount = models.size,
isActive = provider.isActive,
isPreConfigured = provider.isPreConfigured,
hasApiKey = hasKey,
connectionStatus = if (!hasKey) ConnectionStatus.NOT_CONFIGURED
else ConnectionStatus.DISCONNECTED
// NOTE: We don't automatically test connections on list load.
// Connection status can be updated when user navigates to detail.
)
}
_uiState.update { it.copy(providers = items, isLoading = false) }
}
}
}
}
ProviderDetailViewModel
class ProviderDetailViewModel(
private val providerRepository: ProviderRepository,
private val apiKeyStorage: ApiKeyStorage,
private val testConnectionUseCase: TestConnectionUseCase,
private val fetchModelsUseCase: FetchModelsUseCase,
private val setDefaultModelUseCase: SetDefaultModelUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val providerId: String = savedStateHandle["providerId"]
?: throw IllegalArgumentException("providerId is required")
private val _uiState = MutableStateFlow(ProviderDetailUiState())
val uiState: StateFlow<ProviderDetailUiState> = _uiState.asStateFlow()
init {
loadProvider()
observeDefaultModel()
}
private fun loadProvider() {
viewModelScope.launch {
val provider = providerRepository.getProviderById(providerId)
?: return@launch
val models = providerRepository.getModelsForProvider(providerId)
val apiKey = apiKeyStorage.getApiKey(providerId)
val masked = maskApiKey(apiKey)
_uiState.update {
it.copy(
provider = provider,
models = models,
apiKeyMasked = masked,
apiKeyFull = apiKey ?: "",
isActive = provider.isActive,
isPreConfigured = provider.isPreConfigured,
isLoading = false
)
}
}
}
private fun observeDefaultModel() {
viewModelScope.launch {
providerRepository.getGlobalDefaultModel().collect { defaultModel ->
_uiState.update {
it.copy(
globalDefaultModelId = defaultModel?.id,
globalDefaultProviderId = defaultModel?.providerId
)
}
}
}
}
fun saveApiKey(apiKey: String) {
viewModelScope.launch {
apiKeyStorage.setApiKey(providerId, apiKey)
_uiState.update {
it.copy(
apiKeyMasked = maskApiKey(apiKey),
apiKeyFull = apiKey,
isEditingApiKey = false,
apiKeyInput = "",
successMessage = "API key saved."
)
}
}
}
fun toggleApiKeyVisibility() {
_uiState.update { it.copy(apiKeyVisible = !it.apiKeyVisible) }
}
fun testConnection() {
viewModelScope.launch {
_uiState.update { it.copy(isTestingConnection = true, connectionTestResult = null) }
when (val result = testConnectionUseCase(providerId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isTestingConnection = false,
connectionTestResult = result.data
)
}
// If connection succeeded, also fetch models
if (result.data.success) {
refreshModels()
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isTestingConnection = false,
errorMessage = result.message
)
}
}
}
}
}
fun refreshModels() {
viewModelScope.launch {
_uiState.update { it.copy(isRefreshingModels = true) }
when (val result = fetchModelsUseCase(providerId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
models = result.data,
isRefreshingModels = false
)
}
}
is AppResult.Error -> {
_uiState.update {
it.copy(
isRefreshingModels = false,
errorMessage = result.message
)
}
}
}
}
}
fun setDefaultModel(modelId: String) {
viewModelScope.launch {
when (val result = setDefaultModelUseCase(modelId, providerId)) {
is AppResult.Success -> {
_uiState.update { it.copy(successMessage = "Default model set.") }
}
is AppResult.Error -> {
_uiState.update { it.copy(errorMessage = result.message) }
}
}
}
}
fun toggleProviderActive() {
viewModelScope.launch {
val newActive = !(_uiState.value.isActive)
providerRepository.setProviderActive(providerId, newActive)
_uiState.update { it.copy(isActive = newActive) }
}
}
fun addManualModel(modelId: String, displayName: String?) {
viewModelScope.launch {
when (val result = providerRepository.addManualModel(providerId, modelId, displayName)) {
is AppResult.Success -> {
val updatedModels = providerRepository.getModelsForProvider(providerId)
_uiState.update {
it.copy(
models = updatedModels,
showAddModelDialog = false,
manualModelIdInput = "",
successMessage = "Model added."
)
}
}
is AppResult.Error -> {
_uiState.update { it.copy(errorMessage = result.message) }
}
}
}
}
fun deleteManualModel(modelId: String) {
viewModelScope.launch {
when (val result = providerRepository.deleteManualModel(providerId, modelId)) {
is AppResult.Success -> {
val updatedModels = providerRepository.getModelsForProvider(providerId)
_uiState.update { it.copy(models = updatedModels) }
}
is AppResult.Error -> {
_uiState.update { it.copy(errorMessage = result.message) }
}
}
}
}
fun deleteProvider() {
viewModelScope.launch {
when (val result = providerRepository.deleteProvider(providerId)) {
is AppResult.Success -> {
// Navigate back -- handled by UI observing a navigation event
_uiState.update { it.copy(successMessage = "Provider deleted.") }
}
is AppResult.Error -> {
_uiState.update { it.copy(errorMessage = result.message) }
}
}
}
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
fun clearSuccess() {
_uiState.update { it.copy(successMessage = null) }
}
private fun maskApiKey(apiKey: String?): String {
if (apiKey.isNullOrEmpty()) return ""
if (apiKey.length <= 8) return "****${apiKey.takeLast(4)}"
val prefix = apiKey.take(3)
val suffix = apiKey.takeLast(4)
return "$prefix....$suffix"
}
}
Screen Composable Outlines
The following are structural outlines. Full Compose code will be generated during implementation, referencing the UI Design Spec for exact spacing, colors, and styling.
ProviderListScreen
@Composable
fun ProviderListScreen(
viewModel: ProviderListViewModel = koinViewModel(),
onProviderClick: (String) -> Unit, // Navigate to provider detail
onAddCustomProvider: () -> Unit, // Navigate to create custom provider
onNavigateBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Providers") },
navigationIcon = { BackButton(onNavigateBack) },
actions = { IconButton(onClick = onAddCustomProvider) { Icon(Icons.Default.Add, "Add") } }
)
}
) { padding ->
if (uiState.isLoading) {
CenteredLoadingIndicator()
} else {
LazyColumn(contentPadding = padding) {
// Pre-configured providers section
val preConfigured = uiState.providers.filter { it.isPreConfigured }
items(preConfigured, key = { it.id }) { provider ->
ProviderListItem(provider, onClick = { onProviderClick(provider.id) })
}
// Custom providers section
val custom = uiState.providers.filter { !it.isPreConfigured }
if (custom.isNotEmpty()) {
stickyHeader { SectionHeader("CUSTOM") }
items(custom, key = { it.id }) { provider ->
ProviderListItem(provider, onClick = { onProviderClick(provider.id) })
}
}
}
}
}
}
ProviderDetailScreen
@Composable
fun ProviderDetailScreen(
viewModel: ProviderDetailViewModel = koinViewModel(),
onNavigateBack: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(uiState.provider?.name ?: "") },
navigationIcon = { BackButton(onNavigateBack) }
)
},
snackbarHost = { /* For success/error messages */ }
) { padding ->
LazyColumn(contentPadding = padding) {
// API Key Section
item { ApiKeySection(uiState, viewModel) }
// Test Connection Button + Result
item { TestConnectionSection(uiState, viewModel) }
// Model List
item { SectionHeader("AVAILABLE MODELS") }
items(uiState.models, key = { "${it.id}_${it.providerId}" }) { model ->
ModelListItem(
model = model,
isDefault = model.id == uiState.globalDefaultModelId
&& model.providerId == uiState.globalDefaultProviderId,
onSetDefault = { viewModel.setDefaultModel(model.id) },
onDelete = if (model.source == ModelSource.MANUAL)
({ viewModel.deleteManualModel(model.id) }) else null
)
}
item { RefreshModelsButton(uiState.isRefreshingModels, viewModel::refreshModels) }
item { AddManualModelButton(viewModel) }
// Active Toggle
item { ActiveToggleSection(uiState.isActive, viewModel::toggleProviderActive) }
// Delete Button (custom providers only)
if (uiState.isPreConfigured == false) {
item { DeleteProviderButton(viewModel::deleteProvider) }
}
}
}
}
SetupScreen
@Composable
fun SetupScreen(
onComplete: () -> Unit, // Navigate to chat
onSkip: () -> Unit // Navigate to chat (skip setup)
) {
// Uses local state or a SetupViewModel
// Step 1: Choose provider (provider cards for OpenAI, Anthropic, Gemini, Custom)
// Step 2: Enter API key, test connection
// Step 3: Select default model, tap "Get Started"
// "Skip for now" button at the bottom
}
The Setup screen reuses logic from ProviderDetailViewModel (test connection, fetch models, set default). It can either use its own dedicated ViewModel or share components. During implementation, a dedicated SetupViewModel is recommended to keep the flow state simple and self-contained.
Koin Dependency Injection
// Additions to existing Koin modules
// AppModule.kt
val appModule = module {
single { ApiKeyStorage(androidContext()) }
single { ModelApiAdapterFactory(get()) } // get() = OkHttpClient
}
// DatabaseModule.kt -- AppDatabase creation with callback
val databaseModule = module {
single {
Room.databaseBuilder(androidContext(), AppDatabase::class.java, "oneclaw.db")
.addCallback(AppDatabaseCallback())
.build()
}
single { get<AppDatabase>().providerDao() }
single { get<AppDatabase>().modelDao() }
// ... other DAOs
}
// RepositoryModule.kt
val repositoryModule = module {
single<ProviderRepository> {
ProviderRepositoryImpl(get(), get(), get(), get())
// ProviderDao, ModelDao, ApiKeyStorage, ModelApiAdapterFactory
}
}
// FeatureModule.kt -- Provider feature
val featureModule = module {
// Use Cases
factory { TestConnectionUseCase(get()) }
factory { FetchModelsUseCase(get()) }
factory { SetDefaultModelUseCase(get()) }
// ViewModels
viewModel { ProviderListViewModel(get(), get()) }
viewModel { ProviderDetailViewModel(get(), get(), get(), get(), get(), get()) }
}
First-Time Setup Flow
Detection Logic
The app determines whether to show the Setup screen on launch:
// In MainActivity or NavGraph setup
val settingsRepository: SettingsRepository = get()
val hasCompletedSetup = settingsRepository.getBoolean("has_completed_setup", false)
if (!hasCompletedSetup) {
// Navigate to Setup screen
navController.navigate(Route.Setup.path)
} else {
// Navigate to Chat (new conversation)
navController.navigate(Route.NewChat.path)
}
Completing Setup
When the user finishes setup (taps “Get Started”) or skips:
// In SetupViewModel or SetupScreen
settingsRepository.setBoolean("has_completed_setup", true)
// Navigate to chat
Once has_completed_setup is true, the Setup screen is never shown again. The setting is stored in the app_settings Room table.
Skip Behavior
When the user taps “Skip for now”:
has_completed_setupis set totrue- Navigate to the chat screen
- The user can browse and manage agents, but cannot send messages
- When the user tries to send a message with no active configured provider, an inline error appears:
- “No provider configured. Go to Settings to add one.”
- The error message links to Settings > Manage Providers
Data Flow Examples
Flow 1: User Adds API Key to Pre-configured Provider
1. User navigates: Settings > Manage Providers > OpenAI
2. ProviderDetailScreen loads
-> ProviderDetailViewModel.loadProvider()
-> Reads ProviderEntity from ProviderDao (already seeded)
-> Reads API key from ApiKeyStorage (null -- not set)
-> UI shows: API key field empty, "Not configured" status
3. User enters API key, taps Save
-> ProviderDetailViewModel.saveApiKey(key)
-> ApiKeyStorage.setApiKey("provider-openai", key)
-> UI updates masked key display
4. User taps "Test Connection"
-> ProviderDetailViewModel.testConnection()
-> TestConnectionUseCase("provider-openai")
-> ProviderRepositoryImpl.testConnection()
-> Reads Provider from DAO
-> Reads API key from ApiKeyStorage
-> Gets OpenAiAdapter from factory
-> Calls adapter.testConnection() -> adapter.listModels()
-> HTTP GET https://api.openai.com/v1/models
-> Parses response, filters chat models
-> Returns ConnectionTestResult(success=true, modelCount=12)
-> UI shows: "Connection successful. Found 12 models."
5. Auto-refresh models (triggered by successful test)
-> ProviderDetailViewModel.refreshModels()
-> FetchModelsUseCase("provider-openai")
-> ProviderRepositoryImpl.fetchModelsFromApi()
-> Deletes existing DYNAMIC models for this provider
-> Inserts new DYNAMIC models
-> Returns merged model list (DYNAMIC + PRESET)
-> UI shows updated model list
6. User selects default model (taps star next to gpt-4o)
-> ProviderDetailViewModel.setDefaultModel("gpt-4o")
-> SetDefaultModelUseCase("gpt-4o", "provider-openai")
-> ModelDao.updateDefaultModel() (clears old default, sets new)
Flow 2: User Adds Custom Provider
1. User navigates: Settings > Manage Providers > [+] Add
2. UI shows a form: name, API base URL, protocol type (OPENAI/ANTHROPIC/GEMINI)
3. User fills in:
- Name: "My Local Ollama"
- URL: "http://192.168.1.100:11434/v1"
- Protocol: OPENAI (Ollama is OpenAI-compatible)
4. Provider created:
-> ProviderRepositoryImpl.createProvider(Provider(
id = UUID.randomUUID(),
name = "My Local Ollama",
type = ProviderType.OPENAI,
apiBaseUrl = "http://192.168.1.100:11434/v1",
isPreConfigured = false,
isActive = true,
...
))
5. User enters API key (or leaves empty if not required by endpoint)
6. User manually adds models (since custom endpoints may not support /models)
-> ProviderDetailViewModel.addManualModel("llama3", "Llama 3")
7. User tests connection if applicable
Error Handling
Error Scenarios and User-Facing Messages
| Scenario | ErrorCode | User Message | UI Behavior |
|---|---|---|---|
| Invalid API key (401/403) | AUTH_ERROR | “Authentication failed. Please check your API key.” | Show in connection test result area |
| Network unreachable | NETWORK_ERROR | “Cannot reach the server. Please check the URL and your network.” | Show in connection test result area |
| Request timeout | TIMEOUT_ERROR | “Connection timed out.” | Show in connection test result area |
| Empty model list from API | PROVIDER_ERROR | “No models found. Using preset models.” | Fall back to preset models silently |
| Delete default provider’s model | VALIDATION_ERROR | “Cannot delete the global default model.” | Show as Snackbar |
| Delete pre-configured provider | VALIDATION_ERROR | “Cannot delete pre-configured providers.” | Show as Snackbar |
| Duplicate manual model | VALIDATION_ERROR | “Model already exists for this provider.” | Show as Snackbar |
| No provider configured (send message) | VALIDATION_ERROR | “No provider configured. Go to Settings to add one.” | Inline error in chat |
API Key Validation
Before testing a connection, perform basic format validation:
fun validateApiKeyFormat(key: String, providerType: ProviderType): String? {
if (key.isBlank()) return "API key cannot be empty."
return when (providerType) {
ProviderType.OPENAI -> {
if (!key.startsWith("sk-")) "OpenAI keys typically start with 'sk-'."
else null
}
ProviderType.ANTHROPIC -> {
if (!key.startsWith("sk-ant-")) "Anthropic keys typically start with 'sk-ant-'."
else null
}
ProviderType.GEMINI -> null // Gemini keys don't have a consistent prefix
}
}
This is a soft warning, not a blocking validation. The key can still be saved and tested even if the format doesn’t match (some proxy services use non-standard key formats).
Implementation Steps
Phase 1: Data Layer
- Create
ProviderEntityandModelEntityindata/local/entity/ - Create
ProviderDaoandModelDaoindata/local/dao/ - Create entity-domain mappers in
data/local/mapper/ - Create
AppDatabaseCallbackwith provider and model seeding - Register entities, DAOs, and callback in
AppDatabase - Create
ApiKeyStorageindata/security/ - Create
ConnectionTestResultandConnectionErrorTypeincore/model/
Phase 2: Provider Adapters
- Create
ModelApiAdapterinterface indata/remote/adapter/ - Create DTOs:
OpenAiModelListResponse,AnthropicModelListResponse,GeminiModelListResponse - Implement
OpenAiAdapter(listModels + testConnection) - Implement
AnthropicAdapter(listModels + testConnection) - Implement
GeminiAdapter(listModels + testConnection) - Create
ModelApiAdapterFactory
Phase 3: Repository & Use Cases
- Update
ProviderRepositoryinterface incore/repository/ - Implement
ProviderRepositoryImplindata/repository/ - Create
TestConnectionUseCaseinfeature/provider/usecase/ - Create
FetchModelsUseCase - Create
SetDefaultModelUseCase
Phase 4: UI Layer
- Create
ProviderListUiState,ProviderDetailUiState,SetupUiState - Implement
ProviderListViewModel - Implement
ProviderDetailViewModel - Implement
ProviderListScreen(Compose) - Implement
ProviderDetailScreen(Compose) - Implement
SetupScreen(Compose) - Register navigation routes in NavGraph
Phase 5: DI & Integration
- Update Koin modules (AppModule, DatabaseModule, RepositoryModule, FeatureModule)
- Add first-launch detection logic in MainActivity/NavGraph
- End-to-end testing: add provider -> test connection -> fetch models -> set default
Testing Strategy
Unit Tests
ApiKeyStorage: Verify set/get/delete/has operationsProviderRepositoryImpl: Verify CRUD, model fetch fallback logic, delete restrictionsOpenAiAdapter.listModels(): Mock OkHttp responses, verify parsing and filteringAnthropicAdapter.listModels(): Mock OkHttp responses, verify parsingGeminiAdapter.listModels(): Mock OkHttp responses, verify model ID extractionFetchModelsUseCase: Verify fallback to preset models on fetch failureSetDefaultModelUseCase: Verify validation checksProviderDetailViewModel: Verify state updates for all actions- Entity-domain mappers: Verify correct field mapping
Integration Tests (Instrumented)
- Database seeding: Verify 3 providers and 8 preset models are created on first launch
- API key encryption: Verify keys are stored encrypted and retrievable
- Full flow: Create provider -> save key -> test -> fetch models -> set default
UI Tests
- Provider list shows pre-configured providers
- Provider detail shows masked API key
- Eye icon toggles API key visibility
- Test connection shows loading state then result
- Model list displays correctly with source labels
- Setup screen flow: choose provider -> enter key -> test -> select model -> complete
- Setup screen skip navigates to chat
Layer 2 Visual Verification Flows
Each flow is independent. State the preconditions before running. Screenshot after each numbered step that says “Screenshot”.
Flow 3-1: First Launch — Welcome Screen Appears
Precondition: Fresh install (or app data cleared via adb shell pm clear com.oneclaw.shadow).
Goal: Verify the Setup/Welcome screen is shown on first launch, not the Chat screen.
Steps:
1. adb shell am start -n com.oneclaw.shadow/.MainActivity
2. Screenshot -> Verify: Setup/Welcome screen visible, NOT the Chat screen.
Expected: Provider selection list (OpenAI, Anthropic, Google Gemini) visible.
Expected: "Skip" button visible in top-right or bottom area.
Flow 3-2: Skip Setup — Direct to Chat
Precondition: Fresh install or app data cleared.
Goal: Verify "Skip" on the setup screen navigates to Chat with no crash.
Steps:
1. Launch app (Setup screen shown).
2. Tap "Skip".
3. Screenshot -> Verify: Chat screen is shown (empty state, input field visible).
Expected: No Setup screen, no crash.
4. Re-launch the app (force-stop + restart).
5. Screenshot -> Verify: Chat screen shown directly (Setup NOT shown again after skip).
Flow 3-3: Enter API Key and Save
Precondition: App on Setup screen OR navigate: Settings -> Provider List -> Anthropic.
Goal: Verify entering and saving an API key persists correctly.
Steps:
1. Navigate to Anthropic provider detail screen.
2. Screenshot -> Verify: API key field shows masked placeholder or empty; Save button present.
3. Tap the API key field and enter a valid Anthropic API key.
4. Tap "Save".
5. Screenshot -> Verify: API key field shows masked value (not plain text).
Expected: Key is saved — navigating away and back still shows the masked value.
6. Navigate away (back to provider list) and re-open Anthropic detail.
7. Screenshot -> Verify: Masked API key still shown (key persisted across navigation).
Flow 3-4: Test Connection — Success
Precondition: Valid API key saved for Anthropic provider (run Flow 3-3 first).
Goal: Verify "Test Connection" reports success with a valid key.
Steps:
1. Open Anthropic provider detail (valid key already saved).
2. Tap "Test Connection".
3. Screenshot immediately -> Verify: Loading indicator / "Testing..." state shown.
4. Wait for result (up to 10 seconds).
5. Screenshot -> Verify: Success result shown (green/Connected chip or success message).
Expected: Provider chip in list shows "Connected" status.
Flow 3-5: Test Connection — Failure (Invalid Key)
Precondition: Navigate to any provider detail screen.
Goal: Verify "Test Connection" shows a clear error with an invalid/wrong key.
Steps:
1. Open Anthropic provider detail.
2. Enter an obviously invalid API key (e.g., "invalid-key-123") and save.
3. Tap "Test Connection".
4. Wait for result.
5. Screenshot -> Verify: Error result shown (red/error chip or error message with reason).
Expected: Error message indicates authentication failure or invalid key.
Expected: Provider chip in list does NOT show "Connected".
Flow 3-6: Toggle API Key Visibility
Precondition: A provider with a saved API key (run Flow 3-3 first).
Goal: Verify the eye icon toggles between masked and visible key text.
Steps:
1. Open Anthropic provider detail (key already saved and masked).
2. Screenshot -> Verify: API key field shows masked value (dots or asterisks).
3. Tap the eye icon next to the API key field.
4. Screenshot -> Verify: API key field shows the actual key text (plain text visible).
5. Tap the eye icon again.
6. Screenshot -> Verify: API key field is masked again.
Flow 3-7: Fetch and Display Model List
Precondition: Valid API key saved and connection tested successfully (Flow 3-4 passed).
Goal: Verify model list loads from the API and displays correctly.
Steps:
1. Open Anthropic provider detail.
2. Tap "Refresh Models" (or the refresh icon).
3. Screenshot -> Verify: Loading indicator shown while fetching.
4. Wait for fetch to complete (up to 10 seconds).
5. Screenshot -> Verify: Model list populated.
Expected: Models shown with display name and source label (PRESET or API).
Expected: At least the preset models (Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5) visible.
Flow 3-8: Set Default Model
Precondition: Model list loaded (Flow 3-7 passed).
Goal: Verify tapping the star icon marks a model as default.
Steps:
1. Open Anthropic provider detail, model list visible.
2. Identify a non-default model (no star filled).
3. Tap its star icon.
4. Screenshot -> Verify: That model now shows a filled star; previous default (if any) star cleared.
5. Navigate away and re-open the provider detail.
6. Screenshot -> Verify: The selected model still shows as default (persisted).
Security Considerations
- API keys never in Room: Keys are stored exclusively in EncryptedSharedPreferences. The providers table has no api_key column.
- No logging: API keys must never appear in log statements, even in debug builds. Use
maskApiKey()if logging is needed for debugging. - HTTPS enforcement: All provider API calls use HTTPS. Custom providers with HTTP URLs should show a warning in the UI (but not block, since local endpoints like Ollama may use HTTP).
- Memory: API keys read from EncryptedSharedPreferences should not be cached in long-lived variables. Read on demand, use, discard.
Dependencies
Depends On
- RFC-000 (Overall Architecture): Project structure, Koin setup, Room database, OkHttp client
Depended On By
- RFC-001 (Chat Interaction): Needs
ModelApiAdapter.sendMessageStream(), provider/model resolution - RFC-002 (Agent Management): Agents reference preferred provider/model
- RFC-004 (Tool System): Tool definitions formatted by adapter for provider-specific API calls
- RFC-005 (Session Management): Sessions track which provider/model was used
Open Questions
- Should we support paginated model list responses? (Anthropic and Gemini may paginate if there are many models. For V1, we can fetch only the first page.)
- HTTP warning for custom providers: Should we block HTTP or just warn? (Recommendation: warn only, since local endpoints commonly use HTTP.)
Differences from RFC-000
This RFC introduces the following changes that need to be reflected back in RFC-000:
ProviderType.CUSTOMremoved:ProviderTypenow only hasOPENAI,ANTHROPIC,GEMINI. Thetypefield represents API protocol format, not service identity.isPreConfigureddistinguishes built-in from user-created providers.ProviderRepositoryinterface expanded: AddedaddManualModel(),deleteManualModel(),setProviderActive(),getActiveProviders().ConnectionTestResultmodel added: New domain model incore/model/.ModelApiAdapterFactorytakesOkHttpClient: Updated constructor signature.
References
- FEAT-003 PRD – Functional requirements
- UI Design Spec – Visual specifications for Sections 3, 4, 7
- RFC-000 Overall Architecture – Project structure and data models
- OpenAI Models API
- Anthropic Models API
- Gemini Models API
- EncryptedSharedPreferences
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-02-27 | 0.1 | Initial version | - |