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) + } + } +}