From 8c63c11ea9cee099e33f6b66e10660e12e565720 Mon Sep 17 00:00:00 2001
From: ciddwd <572242998@qq.com>
Date: Sat, 24 Oct 2026 09:00:24 -0700
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=A1=85=E5=9F=BA?=
=?UTF-8?q?=E6=B5=81=E5=8A=A8=E5=8F=8C=E7=AB=99=E4=BA=91=E7=AB=AF=E7=BF=BB?=
=?UTF-8?q?=E8=AF=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../onboarding/CloudProviderDocumentation.kt | 2 +
.../app/onboarding/OnboardingModels.kt | 8 +++
.../app/onboarding/OnboardingScreen.kt | 2 +
.../app/translate/OpenAiModelListPolicy.kt | 19 +++++
.../gameocr/app/translate/OpenAiTranslator.kt | 2 +-
app/src/main/res/values-zh-rCN/strings.xml | 2 +
app/src/main/res/values/strings.xml | 2 +
.../CloudConnectionUiContractTest.kt | 2 +
.../onboarding/OnboardingCloudProviderTest.kt | 32 ++++++++-
.../app/onboarding/OnboardingPolicyTest.kt | 10 +++
.../translate/OpenAiModelListPolicyTest.kt | 69 +++++++++++++++++++
11 files changed, 147 insertions(+), 3 deletions(-)
create mode 100644 app/src/main/java/com/gameocr/app/translate/OpenAiModelListPolicy.kt
create mode 100644 app/src/test/java/com/gameocr/app/translate/OpenAiModelListPolicyTest.kt
diff --git a/app/src/main/java/com/gameocr/app/onboarding/CloudProviderDocumentation.kt b/app/src/main/java/com/gameocr/app/onboarding/CloudProviderDocumentation.kt
index 54a2265..5ca7c69 100644
--- a/app/src/main/java/com/gameocr/app/onboarding/CloudProviderDocumentation.kt
+++ b/app/src/main/java/com/gameocr/app/onboarding/CloudProviderDocumentation.kt
@@ -16,5 +16,7 @@ internal val CloudProvider.documentationUrl: String?
CloudProvider.AIHUBMIX -> "https://docs.aihubmix.com/cn"
CloudProvider.AI_302 -> "https://doc.302.ai/"
CloudProvider.SCNET -> "https://www.scnet.cn/ac/openapi/doc/2.0/moduleapi/overview.html"
+ CloudProvider.SILICONFLOW_CN -> "https://docs.siliconflow.cn/docs/api/models-get"
+ CloudProvider.SILICONFLOW -> "https://docs.siliconflow.com/en/api-reference/models/get-model-list"
CloudProvider.CUSTOM -> null
}
diff --git a/app/src/main/java/com/gameocr/app/onboarding/OnboardingModels.kt b/app/src/main/java/com/gameocr/app/onboarding/OnboardingModels.kt
index 8f43d25..01f74d5 100644
--- a/app/src/main/java/com/gameocr/app/onboarding/OnboardingModels.kt
+++ b/app/src/main/java/com/gameocr/app/onboarding/OnboardingModels.kt
@@ -142,6 +142,14 @@ enum class CloudProvider(
baseUrl = "https://api.scnet.cn/api/llm/v1/",
sortName = "SCNet",
),
+ SILICONFLOW_CN(
+ displayName = "SiliconFlow CN",
+ baseUrl = "https://api.siliconflow.cn/v1/",
+ ),
+ SILICONFLOW(
+ displayName = "SiliconFlow",
+ baseUrl = "https://api.siliconflow.com/v1/",
+ ),
CUSTOM(
displayName = "Custom",
baseUrl = "",
diff --git a/app/src/main/java/com/gameocr/app/onboarding/OnboardingScreen.kt b/app/src/main/java/com/gameocr/app/onboarding/OnboardingScreen.kt
index a98575b..4c82efb 100644
--- a/app/src/main/java/com/gameocr/app/onboarding/OnboardingScreen.kt
+++ b/app/src/main/java/com/gameocr/app/onboarding/OnboardingScreen.kt
@@ -1388,6 +1388,8 @@ private fun cloudProviderLabel(provider: CloudProvider): String = when (provider
CloudProvider.CUSTOM -> stringResource(R.string.onboarding_cloud_custom)
CloudProvider.MODELSCOPE -> stringResource(R.string.onboarding_cloud_provider_modelscope)
CloudProvider.SCNET -> stringResource(R.string.onboarding_cloud_provider_scnet)
+ CloudProvider.SILICONFLOW_CN -> stringResource(R.string.onboarding_cloud_provider_siliconflow_cn)
+ CloudProvider.SILICONFLOW -> stringResource(R.string.onboarding_cloud_provider_siliconflow)
else -> provider.displayName
}
diff --git a/app/src/main/java/com/gameocr/app/translate/OpenAiModelListPolicy.kt b/app/src/main/java/com/gameocr/app/translate/OpenAiModelListPolicy.kt
new file mode 100644
index 0000000..42580eb
--- /dev/null
+++ b/app/src/main/java/com/gameocr/app/translate/OpenAiModelListPolicy.kt
@@ -0,0 +1,19 @@
+package com.gameocr.app.translate
+
+import okhttp3.HttpUrl
+
+/** SiliconFlow documents sub_type=chat for chat-capable models, including vision models.
+ * https://docs.siliconflow.cn/docs/api/models-get
+ * https://docs.siliconflow.com/en/api-reference/models/get-model-list
+ * Only translation calls this policy; the shared ASR model-list client stays unfiltered.
+ */
+internal fun chatModelListUrl(url: HttpUrl): HttpUrl = if (
+ url.scheme == "https" && url.port == 443 &&
+ url.host in setOf("api.siliconflow.cn", "api.siliconflow.com") &&
+ url.encodedPath == "/v1/models" && url.username.isEmpty() && url.password.isEmpty() &&
+ url.query == null && url.fragment == null
+) {
+ url.newBuilder().addQueryParameter("sub_type", "chat").build()
+} else {
+ url
+}
diff --git a/app/src/main/java/com/gameocr/app/translate/OpenAiTranslator.kt b/app/src/main/java/com/gameocr/app/translate/OpenAiTranslator.kt
index 94c8f43..ca0f631 100644
--- a/app/src/main/java/com/gameocr/app/translate/OpenAiTranslator.kt
+++ b/app/src/main/java/com/gameocr/app/translate/OpenAiTranslator.kt
@@ -679,7 +679,7 @@ class OpenAiTranslator @Inject constructor(
val timedClient = client.withApiTimeout(
OpenAiRequestPolicy.remoteLlmTimeoutSeconds(settings.apiTimeoutSeconds),
)
- val modelsUrl = (ensureSlash(settings.baseUrl) + "models").toHttpUrl()
+ val modelsUrl = chatModelListUrl((ensureSlash(settings.baseUrl) + "models").toHttpUrl())
val modelsResult = try {
modelListClient.fetch(
modelsUrl, settings.apiKey,
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 254e997..4f8aa9c 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -2134,6 +2134,8 @@
国际
魔搭 ModelScope
国家超算互联网(SCNet)
+ 硅基流动
+ SiliconFlow
Base URL
API Key
模型名称
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 31cd777..aaa1bdd 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2136,6 +2136,8 @@
International
ModelScope
SCNet
+ SiliconFlow CN
+ SiliconFlow
Base URL
API key
Model name
diff --git a/app/src/test/java/com/gameocr/app/onboarding/CloudConnectionUiContractTest.kt b/app/src/test/java/com/gameocr/app/onboarding/CloudConnectionUiContractTest.kt
index 8748e81..3d06568 100644
--- a/app/src/test/java/com/gameocr/app/onboarding/CloudConnectionUiContractTest.kt
+++ b/app/src/test/java/com/gameocr/app/onboarding/CloudConnectionUiContractTest.kt
@@ -18,6 +18,8 @@ class CloudConnectionUiContractTest {
CloudProvider.MODELSCOPE to "modelscope.cn", CloudProvider.OPENROUTER to "openrouter.ai",
CloudProvider.AIHUBMIX to "docs.aihubmix.com", CloudProvider.AI_302 to "doc.302.ai",
CloudProvider.SCNET to "www.scnet.cn",
+ CloudProvider.SILICONFLOW_CN to "docs.siliconflow.cn",
+ CloudProvider.SILICONFLOW to "docs.siliconflow.com",
)
assertEquals(CloudProvider.entries.toSet() - CloudProvider.CUSTOM, hosts.keys)
hosts.forEach { (provider, host) ->
diff --git a/app/src/test/java/com/gameocr/app/onboarding/OnboardingCloudProviderTest.kt b/app/src/test/java/com/gameocr/app/onboarding/OnboardingCloudProviderTest.kt
index c62b349..2c50c65 100644
--- a/app/src/test/java/com/gameocr/app/onboarding/OnboardingCloudProviderTest.kt
+++ b/app/src/test/java/com/gameocr/app/onboarding/OnboardingCloudProviderTest.kt
@@ -32,7 +32,8 @@ class OnboardingCloudProviderTest {
CloudProvider.DEEPSEEK, CloudProvider.GEMINI, CloudProvider.GLM,
CloudProvider.KIMI, CloudProvider.MIMO, CloudProvider.MINIMAX,
CloudProvider.MODELSCOPE, CloudProvider.OPENAI, CloudProvider.OPENROUTER,
- CloudProvider.SCNET, CloudProvider.CUSTOM,
+ CloudProvider.SCNET, CloudProvider.SILICONFLOW, CloudProvider.SILICONFLOW_CN,
+ CloudProvider.CUSTOM,
)
assertEquals(expected, CloudProvider.sortedChoices)
CloudProvider.sortedChoices.filterNot { it == CloudProvider.CUSTOM }.zipWithNext().forEach { (a, b) ->
@@ -189,7 +190,8 @@ class OnboardingCloudProviderTest {
@Test
fun aggregatorSetupSmoke_requiresUserModelThenUsesTheExistingOpenAiPipeline() {
listOf(CloudProvider.MODELSCOPE, CloudProvider.OPENROUTER, CloudProvider.AIHUBMIX,
- CloudProvider.AI_302, CloudProvider.SCNET).forEach { provider ->
+ CloudProvider.AI_302, CloudProvider.SCNET, CloudProvider.SILICONFLOW_CN,
+ CloudProvider.SILICONFLOW).forEach { provider ->
val draft = OnboardingPolicy.selectCloudProvider(
OnboardingDraft(translationMethod = OnboardingTranslationMethod.CLOUD_LLM), provider,
).copy(cloudApiKey = "test-key")
@@ -234,6 +236,32 @@ class OnboardingCloudProviderTest {
assertTrue(source.contains("cloudProviderLabel(draft.cloudProvider)"))
}
+ @Test
+ fun siliconFlowSites_setupAndReopenPreserveUserChosenModelAndCredentials() {
+ listOf(CloudProvider.SILICONFLOW_CN, CloudProvider.SILICONFLOW).forEach { provider ->
+ val draft = OnboardingPolicy.selectCloudProvider(
+ OnboardingDraft(translationMethod = OnboardingTranslationMethod.CLOUD_LLM), provider,
+ )
+ assertEquals("", draft.cloudModel)
+ assertEquals("", draft.cloudApiKey)
+ val configured = draft.copy(cloudModel = "org/user-chosen-model", cloudApiKey = "${provider.name}-key")
+ val saved = OnboardingPolicy.apply(Settings(), configured)
+ assertEquals(TranslatorEngine.OPENAI, saved.translatorEngine)
+ val restored = OnboardingPolicy.fromSettings(saved)
+ assertEquals(provider, restored.cloudProvider)
+ assertEquals(provider.baseUrl, restored.cloudBaseUrl)
+ assertEquals(configured.cloudApiKey, restored.cloudApiKey)
+ assertEquals(configured.cloudModel, restored.cloudModel)
+ val other = if (provider == CloudProvider.SILICONFLOW_CN) CloudProvider.SILICONFLOW else CloudProvider.SILICONFLOW_CN
+ val switched = OnboardingPolicy.selectCloudProvider(restored, other)
+ assertEquals(other.baseUrl, switched.cloudBaseUrl)
+ assertEquals("", switched.cloudApiKey)
+ assertEquals("", switched.cloudModel)
+ assertEquals(configured.cloudApiKey, saved.apiKey)
+ assertEquals(provider.baseUrl, saved.baseUrl)
+ }
+ }
+
private fun sourceFile(path: String): File =
listOf(File(path), File("app", path)).firstOrNull { it.isFile } ?: error("Missing source: $path")
}
diff --git a/app/src/test/java/com/gameocr/app/onboarding/OnboardingPolicyTest.kt b/app/src/test/java/com/gameocr/app/onboarding/OnboardingPolicyTest.kt
index 4208085..555fee0 100644
--- a/app/src/test/java/com/gameocr/app/onboarding/OnboardingPolicyTest.kt
+++ b/app/src/test/java/com/gameocr/app/onboarding/OnboardingPolicyTest.kt
@@ -614,6 +614,16 @@ class OnboardingPolicyTest {
"",
CloudApiProtocol.OPENAI,
),
+ Case(
+ CloudProvider.SILICONFLOW_CN,
+ "https://api.siliconflow.cn/v1/",
+ CloudApiProtocol.OPENAI,
+ ),
+ Case(
+ CloudProvider.SILICONFLOW,
+ "https://api.siliconflow.com/v1/",
+ CloudApiProtocol.OPENAI,
+ ),
)
assertEquals(CloudProvider.entries.size, cases.size)
diff --git a/app/src/test/java/com/gameocr/app/translate/OpenAiModelListPolicyTest.kt b/app/src/test/java/com/gameocr/app/translate/OpenAiModelListPolicyTest.kt
new file mode 100644
index 0000000..c970934
--- /dev/null
+++ b/app/src/test/java/com/gameocr/app/translate/OpenAiModelListPolicyTest.kt
@@ -0,0 +1,69 @@
+package com.gameocr.app.translate
+
+import com.gameocr.app.asr.OpenAiAsrProtocol
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Protocol
+import okhttp3.Response
+import okhttp3.ResponseBody.Companion.toResponseBody
+import org.junit.Assert.*
+import org.junit.Test
+
+class OpenAiModelListPolicyTest {
+ @Test fun officialSites_onlyRequestChatModels_withoutChangingTheHostOrCredential() = runBlocking {
+ listOf("api.siliconflow.cn", "api.siliconflow.com").forEach { host ->
+ var calls = 0
+ val client = OkHttpClient.Builder().addInterceptor { chain ->
+ calls++
+ val request = chain.request()
+ assertEquals("GET", request.method)
+ assertEquals(host, request.url.host)
+ assertEquals("/v1/models", request.url.encodedPath)
+ assertEquals("sub_type=chat", request.url.encodedQuery)
+ assertEquals("Bearer $host-key", request.header("Authorization"))
+ Response.Builder().request(request).protocol(Protocol.HTTP_1_1).code(200)
+ .message("test").body("""{"data":[{"id":"org/chat-model"},{"id":"org/vision-model"}]}""".toResponseBody()).build()
+ }.build()
+ try {
+ val models = OpenAiModelListClient(client, Json).fetch(
+ chatModelListUrl("https://$host/v1/models".toHttpUrl()), "$host-key", 30,
+ )
+ assertEquals(listOf("org/chat-model", "org/vision-model"), models)
+ assertEquals(1, calls)
+ } finally {
+ client.dispatcher.executorService.shutdown()
+ client.connectionPool.evictAll()
+ }
+ }
+ }
+
+ @Test fun customEndpointsAndExplicitQueries_arePreservedExactly() {
+ listOf(
+ "https://api.openai.com/v1/models",
+ "https://gateway.example/v1/models",
+ "https://api.siliconflow.cn.evil.example/v1/models",
+ "https://api.siliconflow.com/v2/models",
+ "https://api.siliconflow.cn/proxy/v1/models",
+ "http://api.siliconflow.cn/v1/models",
+ "https://api.siliconflow.cn:8443/v1/models",
+ "https://user@api.siliconflow.cn/v1/models",
+ "https://api.siliconflow.cn/v1/models?sub_type=speech-to-text",
+ "https://api.siliconflow.cn/v1/models#custom",
+ ).forEach { raw ->
+ val url = raw.toHttpUrl()
+ assertSame(raw, url, chatModelListUrl(url))
+ }
+ assertEquals("chat", chatModelListUrl("https://API.SILICONFLOW.COM:443/v1/models".toHttpUrl())
+ .queryParameter("sub_type"))
+ }
+
+ @Test fun speechModelLists_neverUseTheChatFilter() {
+ listOf("cn", "com").forEach { site ->
+ val url = OpenAiAsrProtocol.modelsEndpoint("https://api.siliconflow.$site/v1")!!
+ assertEquals("https://api.siliconflow.$site/v1/models", url.toString())
+ assertNull(url.query)
+ }
+ }
+}