Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions app/src/main/java/com/frischar/fantareal/domain/llm/ModelCatalog.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.frischar.fantareal.domain.llm

data class ModelProviderPreset(
val id: String,
val label: String,
val baseUrl: String,
val recommendedModel: String,
val helper: String,
val requiresApiKey: Boolean = true
)

object ModelProviderPresets {
val all = listOf(
ModelProviderPreset(
id = "custom",
label = "自定义",
baseUrl = "",
recommendedModel = "",
helper = "保留当前填写内容,适合本地或其他 OpenAI-compatible 服务。",
requiresApiKey = false
),
ModelProviderPreset(
id = "deepseek",
label = "DeepSeek",
baseUrl = "https://api.deepseek.com/v1",
recommendedModel = "deepseek-chat",
helper = "填入 DeepSeek 官方兼容地址,并优先选择 deepseek-chat。"
),
ModelProviderPreset(
id = "openai",
label = "OpenAI",
baseUrl = "https://api.openai.com/v1",
recommendedModel = "gpt-4.1-mini",
helper = "填入 OpenAI 官方地址;拉取后以账号实际可用模型为准。"
),
ModelProviderPreset(
id = "minimax",
label = "MiniMax",
baseUrl = "https://api.minimaxi.com/v1",
recommendedModel = "MiniMax-M3",
helper = "填入 MiniMax 国内官方 OpenAI-compatible 地址。"
),
ModelProviderPreset(
id = "openrouter",
label = "OpenRouter",
baseUrl = "https://openrouter.ai/api/v1",
recommendedModel = "openrouter/auto",
helper = "优先使用 openrouter/auto,也可以从账号可用列表中改选。"
),
ModelProviderPreset(
id = "siliconflow",
label = "SiliconFlow",
baseUrl = "https://api.siliconflow.cn/v1",
recommendedModel = "",
helper = "填入 SiliconFlow 地址,拉取后从账号可用模型中选择。"
)
)

fun find(id: String): ModelProviderPreset = all.firstOrNull { it.id == id } ?: all.first()

fun detect(baseUrl: String): ModelProviderPreset {
val normalized = baseUrl.trim().trimEnd('/').lowercase()
return all.firstOrNull { preset ->
preset.baseUrl.isNotBlank() && preset.baseUrl.trimEnd('/').lowercase() == normalized
} ?: all.first()
}
}

internal fun choosePreferredChatModel(
currentModel: String,
recommendedModel: String,
availableModels: List<String>
): String {
val models = availableModels.map(String::trim).filter(String::isNotBlank).distinct()
val current = currentModel.trim()
val recommended = recommendedModel.trim()

if (current in models) return current
if (recommended in models) return recommended

val chatCandidates = models.filterNot(::isClearlyNonChatModel)
return chatCandidates.singleOrNull().orEmpty()
}

internal fun shouldClearApiKeyForProviderSwitch(
currentBaseUrl: String,
nextBaseUrl: String,
apiKey: String
): Boolean {
if (apiKey.isBlank()) return false

val currentProvider = normalizeProviderBaseUrl(currentBaseUrl)
val nextProvider = normalizeProviderBaseUrl(nextBaseUrl)
return nextProvider.isNotBlank() && currentProvider != nextProvider
}

private fun normalizeProviderBaseUrl(value: String): String = value
.trim()
.trimEnd('/')
.removeSuffix("/chat/completions")
.removeSuffix("/models")
.trimEnd('/')
.lowercase()

private fun isClearlyNonChatModel(model: String): Boolean {
val normalized = model.lowercase()
return listOf(
"embedding",
"rerank",
"moderation",
"whisper",
"tts",
"speech",
"audio",
"image",
"dall-e",
"stable-diffusion",
"transcription",
"ocr"
).any(normalized::contains) || normalized.startsWith("bge-")
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
Expand Down Expand Up @@ -67,6 +65,40 @@ class OpenAiProvider(
.connectTimeout(60, TimeUnit.SECONDS)
.build()

suspend fun fetchAvailableModels(): List<String> = withContext(Dispatchers.IO) {
val httpReq = Request.Builder()
.url(normalizeEndpoint(baseUrl, "models"))
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
}
}
.get()
.build()

client.newCall(httpReq).execute().use { response ->
if (!response.isSuccessful) {
throw IllegalStateException("拉取模型失败:HTTP ${response.code}")
}

val responseText = response.body?.string().orEmpty()
val root = runCatching { AppJson.parseToJsonElement(responseText) as? JsonObject }
.getOrNull()
?: throw IllegalStateException("模型列表接口没有返回有效 JSON")
val rows = root["data"] as? JsonArray
?: throw IllegalStateException("模型列表格式无效:缺少 data 数组")

rows.mapNotNull { item ->
(item as? JsonObject)
?.get("id")
?.let { it as? JsonPrimitive }
?.contentOrNull
?.trim()
?.takeIf(String::isNotBlank)
}.distinct()
}
}

fun stream(request: LlmRequest): Flow<LlmStreamEvent> = flow {
val messages = mutableListOf<OpenAiMessage>()
if (request.system != null) {
Expand All @@ -84,7 +116,7 @@ class OpenAiProvider(
try {
val jsonBody = AppJson.encodeToString(openAiReq)
val httpReq = Request.Builder()
.url(normalizeEndpoint(baseUrl))
.url(normalizeEndpoint(baseUrl, "chat/completions"))
.apply {
if (apiKey.isNotBlank()) {
addHeader("Authorization", "Bearer $apiKey")
Expand Down Expand Up @@ -168,18 +200,24 @@ class OpenAiProvider(
}
}.flowOn(Dispatchers.IO)

private fun normalizeEndpoint(value: String): String {
private fun normalizeEndpoint(value: String, endpoint: String): String {
val trimmed = value.trim().trimEnd('/')
require(trimmed.isNotBlank()) { "API base URL is empty" }
require(trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
"API base URL must start with http:// or https://"
}
return if (trimmed.endsWith("/chat/completions")) {
trimmed
} else if (!trimmed.substringAfter("://", "").contains("/")) {
"$trimmed/v1/chat/completions"

val cleanEndpoint = endpoint.trim('/')
val endpointBase = when {
trimmed.endsWith("/chat/completions") -> trimmed.removeSuffix("/chat/completions")
trimmed.endsWith("/models") -> trimmed.removeSuffix("/models")
else -> trimmed
}.trimEnd('/')

return if (!endpointBase.substringAfter("://", "").contains("/")) {
"$endpointBase/v1/$cleanEndpoint"
} else {
"$trimmed/chat/completions"
"$endpointBase/$cleanEndpoint"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ fun AppScaffold() {
onBaseUrlChange = settingsViewModel::updateApiBaseUrl,
onApiKeyChange = settingsViewModel::updateApiKey,
onModelNameChange = settingsViewModel::updateModel,
onProviderPresetChange = settingsViewModel::selectProviderPreset,
onApplyProviderPreset = settingsViewModel::applyProviderPresetAndFetch,
onFetchModels = settingsViewModel::fetchModels,
onTemperatureChange = settingsViewModel::updateTemperature,
onSupportStreamingChange = settingsViewModel::updateSupportStreaming,
onOpenaiFormatChange = settingsViewModel::updateOpenaiFormat,
Expand Down
Loading
Loading